Compare commits
1
Commits
4c3aea8434
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a58ec4d0c |
@@ -1,49 +0,0 @@
|
||||
# --- NordRouter ---
|
||||
# API key from your NordRouter account (starts with sk-)
|
||||
NORDROUTER_API_KEY=sk-your-key-here
|
||||
NORDROUTER_BASE_URL=https://nordrouter.com
|
||||
|
||||
# --- NexusAI general ---
|
||||
NEXUS_DEFAULT_PROVIDER=nordrouter
|
||||
# Where generated media files and the SQLite journal are stored
|
||||
NEXUS_MEDIA_DIR=./media-output
|
||||
# Optional: custom SQLite path (defaults to <media dir>/nexus.sqlite)
|
||||
# NEXUS_DB_PATH=./media-output/nexus.sqlite
|
||||
# Max bytes for embedding image/audio directly in MCP results (default 10MB)
|
||||
NEXUS_EMBED_MAX_BYTES=10485760
|
||||
# Log level: debug | info | warn | error (logs go to stderr)
|
||||
NEXUS_LOG_LEVEL=info
|
||||
|
||||
# --- Managed provider keys (optional, for admin-managed keys) ---
|
||||
# Master secret used to encrypt provider API keys stored in SQLite (AES-256-GCM).
|
||||
# Accepts a 64-char hex string or any passphrase. NEVER stored in the DB.
|
||||
# If unset, only env keys (e.g. NORDROUTER_API_KEY) are used.
|
||||
# Generate: openssl rand -hex 32
|
||||
# NEXUS_MASTER_KEY=
|
||||
# Optional separate DB for the providers store (defaults to NEXUS_DB_PATH)
|
||||
# NEXUS_PROVIDERS_DB_PATH=./media-output/nexus.sqlite
|
||||
# Key precedence: <NAME>_API_KEY env > encrypted DB entry
|
||||
|
||||
# --- Local STT (faster-whisper via uv sidecar) ---
|
||||
# Launcher: "uv" (recommended, auto-installs deps) or a python interpreter path
|
||||
NEXUS_STT_PYTHON=uv
|
||||
# Whisper size (tiny|base|small|medium|large-v3) OR an absolute path to a
|
||||
# pre-downloaded CTranslate2 model directory
|
||||
NEXUS_STT_MODEL=small
|
||||
NEXUS_STT_DEVICE=auto # auto | cpu | cuda
|
||||
NEXUS_STT_COMPUTE=auto # auto | int8 | float16
|
||||
# Offline mode: require a locally present model (no downloads)
|
||||
# NEXUS_STT_LOCAL_ONLY=1
|
||||
# Cache dir for downloaded models
|
||||
# NEXUS_STT_DOWNLOAD_ROOT=./models
|
||||
# Optional custom sidecar path
|
||||
# NEXUS_STT_SIDECAR=/abs/path/to/sidecar/stt_server.py
|
||||
# HuggingFace mirror (for restricted networks)
|
||||
# HF_ENDPOINT=https://hf-mirror.com
|
||||
|
||||
# --- Admin web UI (packages/admin) ---
|
||||
NEXUS_ADMIN_HOST=127.0.0.1
|
||||
NEXUS_ADMIN_PORT=4123
|
||||
|
||||
# --- Proxy (optional) ---
|
||||
# NEXUS_HTTP_PROXY=http://proxy:8080
|
||||
@@ -1,25 +0,0 @@
|
||||
|
||||
# secrets
|
||||
api-sec.txt
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# node
|
||||
node_modules/
|
||||
**/dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# data / media
|
||||
media-output/
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
*.db
|
||||
|
||||
# python
|
||||
sidecar/.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# os
|
||||
.DS_Store
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"nexusai": {
|
||||
"type": "local",
|
||||
"command": [
|
||||
"node",
|
||||
"/home/user/moder/mcp/NexusAI/packages/mcp-server/dist/index.js"
|
||||
],
|
||||
"env": {
|
||||
"NORDROUTER_API_KEY": "sk-your-key-here",
|
||||
"NEXUS_MEDIA_DIR": "/home/user/moder/mcp/NexusAI/media-output",
|
||||
"NEXUS_STT_MODEL": "small"
|
||||
},
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
# AGENTS.md — NexusAI
|
||||
|
||||
> Карта проекта для ИИ-агентов и разработчиков. Читай этот файл первым, чтобы не перечитывать весь код.
|
||||
> Обновляй раздел **«Последние изменения»** и карту при значимых правках.
|
||||
|
||||
---
|
||||
|
||||
## 1. Что это
|
||||
|
||||
**NexusAI** — универсальный **MCP-сервис** для агентов. Даёт один набор инструментов для:
|
||||
- **TTS** (text-to-speech), **STT** (speech-to-text, локально),
|
||||
- генерации **изображений / видео / музыки**,
|
||||
- **веб-поиска** (search-augmented модели),
|
||||
- **vision** (описание изображения chat-моделью),
|
||||
- служебных операций: список моделей, оценка стоимости, загрузка файла, статистика.
|
||||
|
||||
Плюс **веб-админка** (Fastify) для галереи контента, учёта трат и управления провайдерами/ключами.
|
||||
|
||||
Первый и основной провайдер — **NordRouter** (`https://nordrouter.com`, OpenAI-совместимый).
|
||||
Архитектура провайдер-агностична: адаптеры добавляются в `registry`, слой инструментов не меняется.
|
||||
|
||||
---
|
||||
|
||||
## 2. Технологии
|
||||
|
||||
- **TypeScript**, monorepo через **npm workspaces** (CommonJS, `tsc`).
|
||||
- **MCP**: `@modelcontextprotocol/sdk` (stdio-транспорт).
|
||||
- **HTTP**: `undici` (fetch + ProxyAgent + FormData).
|
||||
- **БД**: `better-sqlite3` (журнал + провайдеры).
|
||||
- **Админка**: `fastify` + `@fastify/static`, фронт — ванильный HTML/CSS/JS (без сборки).
|
||||
- **Схемы**: `zod` (+ `zod-to-json-schema` для MCP inputSchema).
|
||||
- **STT**: Python sidecar `faster-whisper`, запускается через `uv`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Карта проекта
|
||||
|
||||
```
|
||||
NexusAI/
|
||||
├── AGENTS.md # ← этот файл
|
||||
├── README.md # пользовательская документация
|
||||
├── TEST_RESUME.md # лог ручных тестов (search/STT/TTS/image/video/vision)
|
||||
├── .env.example # все переменные окружения с описанием
|
||||
├── package.json # workspaces + скрипты build/start/admin
|
||||
├── sidecar/
|
||||
│ └── stt_server.py # faster-whisper sidecar (JSON in argv → JSON stdout)
|
||||
└── packages/
|
||||
├── core/ # @nexusai/core — общая логика, БД, провайдеры
|
||||
├── mcp-server/ # @nexusai/mcp-server — MCP stdio-сервер (инструменты)
|
||||
└── admin/ # @nexusai/admin — веб-дашборд
|
||||
```
|
||||
|
||||
### 3.1 `packages/core` (`@nexusai/core`)
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `src/config.ts` | `loadConfig()` — читает все `NEXUS_*` / `NORDROUTER_*` env → `NexusConfig`. |
|
||||
| `src/index.ts` | Реэкспорт всего публичного API core. |
|
||||
| `src/db/journal.ts` | `Journal` — SQLite журнал генераций: `record/getById/list/deleteById/stats`. |
|
||||
| `src/db/providers-store.ts` | `ProvidersStore` — провайдеры + **зашифрованные** ключи (AES-256-GCM). Отдаёт `ProviderView` с маской. |
|
||||
| `src/security/crypto.ts` | `KeyCipher` (шифрование ключей), `maskSecret`. `enabled` = задан ли мастер-ключ. |
|
||||
| `src/security/resolver.ts` | `KeyResolver` — эффективные настройки провайдера. **Приоритет ключа: ENV > DB**. Грациозный фолбэк, если DB-ключ нечитаем. |
|
||||
| `src/media/storage.ts` | `MediaStorage` — скачивание URL/base64 на диск, `mimeToExt/extToMime`, `cleanup(ttlDays)`. |
|
||||
| `src/media/content.ts` | `buildEmbeddedContent()` — упаковка файла в MCP-контент (image/audio) с лимитом. |
|
||||
| `src/providers/types.ts` | Базовые типы: `Capability`, `MediaProvider`, `SearchProvider`, `SttProvider`, `ModelInfo` и пр. |
|
||||
| `src/providers/registry.ts` | `ProviderRegistry` — карта name+capability → адаптер. |
|
||||
| `src/providers/model-catalog.ts` | **`ModelCatalog`** — фоновая синхронизация каталога моделей + **fuzzy-подбор** модели (LCS + токены, дешёвая при равенстве), `ModelResolutionError` со списком моделей. |
|
||||
| `src/providers/nordrouter/client.ts` | `NordRouterClient` — низкоуровневый HTTP (Bearer, rate-limit). |
|
||||
| `src/providers/nordrouter/media.ts` | `NordRouterMediaProvider` — `/media/generate` + polling, `estimate`, `upload` (data-URL, MIME по расширению), `listModels`, `checkKey`. Capabilities: `tts,image,video,music`. |
|
||||
| `src/providers/nordrouter/search.ts` | `NordRouterSearchProvider` — веб-поиск через `/v1/chat/completions` (perplexity/sonar-pro), citations из annotations. |
|
||||
| `src/providers/nordrouter/index.ts` | `createNordRouterProviders()` — фабрика media+search. |
|
||||
| `src/providers/local-stt/whisper.ts` | `LocalWhisperProvider` — спавнит sidecar, materialize аудио (path/url/base64). |
|
||||
| `src/shared/http.ts` | `HttpClient` (retry, rate-limit, proxy, **FormData**), `RateLimiter`. |
|
||||
| `src/shared/errors.ts` | `NexusError` + `codeFromHttpStatus`. |
|
||||
| `src/shared/logger.ts` | `createLogger` — **пишет только в stderr** (stdout занят MCP). |
|
||||
| `src/shared/polling.ts` | `pollUntilDone`, `sleep`. |
|
||||
|
||||
### 3.2 `packages/mcp-server` (`@nexusai/mcp-server`)
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `src/index.ts` | Точка входа: поднимает MCP `Server` на stdio, регистрирует `ListTools`/`CallTool`. |
|
||||
| `src/context.ts` | `buildContext()` — собирает `AppContext`: registry, storage, journal, providersStore, **catalogs** (Map<provider, ModelCatalog> с авто-синком), defaultProvider. |
|
||||
| `src/schemas.ts` | Zod-схемы входа для всех инструментов. |
|
||||
| `src/tools.ts` | `TOOLS[]` — описания + JSON Schema (генерится из zod). |
|
||||
| `src/handlers.ts` | `Handlers` — реализация всех инструментов, запись в журнал, **резолв модели через catalog**, запись STT-транскрипта в `.md`. |
|
||||
|
||||
### 3.3 `packages/admin` (`@nexusai/admin`)
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `src/server.ts` | Fastify: раздаёт `/media/:file` (с корректным Content-Type, incl. `text/markdown`), статику `public/`, API. |
|
||||
| `src/context.ts` | `buildAdminContext()` — journal + providersStore + cipher (без registry). |
|
||||
| `src/routes.ts` | REST API (см. §6). `KNOWN_TYPES = ['nordrouter','openai']`. |
|
||||
| `public/index.html` | UI: вкладки, темы, галерея, модалка, автообновление. Ванильный CSS через `--vars`. |
|
||||
| `public/app.js` | Логика UI: fetch API, рендер плиток, модалка, аудиоплеер, авто-refresh. |
|
||||
|
||||
---
|
||||
|
||||
## 4. MCP-инструменты (11)
|
||||
|
||||
| Инструмент | Что делает | Ключевые аргументы |
|
||||
|---|---|---|
|
||||
| `nexus_tts` | Синтез речи | `model` (default `audio/elevenlabs-tts`), `input.text/voice/...` |
|
||||
| `nexus_image` | Генерация/редактирование картинок | `model` (default `image/nano-banana-2`), `input.prompt/image/...` |
|
||||
| `nexus_video` | t2v / i2v (может занять минуты) | `model` (default `video/kling-2.6-t2v`), `input.prompt/image/...` |
|
||||
| `nexus_music` | Генерация музыки | `model` (default `music/suno-v5`), `input...` |
|
||||
| `nexus_stt` | Распознавание речи (локально) | `input.audioPath/audioUrl/audioBase64/language/model` |
|
||||
| `nexus_web_search` | Веб-поиск с источниками | `query`, `recency`, `maxResults`, `model` |
|
||||
| `nexus_vision` | Описание изображения chat-моделью | `image` (url/path/base64), `prompt`, `model` (default `google/gemini-3.1-flash-lite`) |
|
||||
| `nexus_media_models` | Список моделей провайдера | `type`, `search` |
|
||||
| `nexus_media_estimate` | Оценка стоимости до генерации | `model`, `input` |
|
||||
| `nexus_media_upload` | Загрузка файла к провайдеру → URL | `path`, `name`, `mimeType` |
|
||||
| `nexus_usage_stats` | Статистика из журнала | `sinceDays`, `list`, `limit` |
|
||||
|
||||
**Медиа-пайплайн** (`Handlers.media`): резолв модели через `ModelCatalog` → `provider.generate` (polling) → скачивание файла в media-dir → запись в журнал → возврат JSON (+ `note` при подмене модели).
|
||||
|
||||
**Fuzzy-резолв модели**: кривое имя → ближайшая (при равенстве — **дешевле**); нет совпадений → `BAD_REQUEST` с `available` (список категории, дешёвые первыми) и подсказкой попросить пользователя уточнить.
|
||||
|
||||
---
|
||||
|
||||
## 5. Возможности (capabilities)
|
||||
|
||||
`Capability = 'tts' | 'stt' | 'image' | 'video' | 'music' | 'search'`
|
||||
(в журнале также встречается `vision`).
|
||||
|
||||
Маппинг NordRouter `type` → capability (в `model-catalog.ts`): `audio→tts`, `image→image`, `video→video`, `music→music`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Admin REST API
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
|---|---|---|
|
||||
| GET | `/api/providers` | Список провайдеров (маска ключа), `knownTypes`, `cryptoEnabled`. |
|
||||
| POST | `/api/providers` | Upsert провайдера (ключ шифруется). |
|
||||
| POST | `/api/providers/:name/enabled` | Вкл/выкл. |
|
||||
| POST | `/api/providers/:name/default` | Сделать default. |
|
||||
| DELETE | `/api/providers/:name` | Удалить. |
|
||||
| POST | `/api/providers/:name/check` | Проверка ключа реальным вызовом. |
|
||||
| GET | `/api/stats?sinceDays=` | Статистика трат. |
|
||||
| GET | `/api/generations?limit=&offset=&capability=` | Галерея (добавляет `mediaUrl`). |
|
||||
| GET | `/api/generations/:id` | Одна запись. |
|
||||
| DELETE | `/api/generations/:id` | Удалить запись + файл (только внутри media-dir). |
|
||||
| GET | `/media/:file` | Раздача файла (Content-Type по расширению). |
|
||||
|
||||
---
|
||||
|
||||
## 7. Переменные окружения (главное)
|
||||
|
||||
```bash
|
||||
# NordRouter
|
||||
NORDROUTER_API_KEY=sk-...
|
||||
NORDROUTER_BASE_URL=https://nordrouter.com
|
||||
# Общие
|
||||
NEXUS_DEFAULT_PROVIDER=nordrouter
|
||||
NEXUS_MEDIA_DIR=./media-output # медиа + SQLite
|
||||
NEXUS_DB_PATH=<media>/nexus.sqlite # опц.
|
||||
NEXUS_MASTER_KEY=<hex32|passphrase> # шифрование ключей в БД (иначе только ENV-ключи)
|
||||
NEXUS_EMBED_MAX_BYTES=10485760
|
||||
NEXUS_LOG_LEVEL=info # логи → stderr
|
||||
# STT
|
||||
NEXUS_STT_PYTHON=uv
|
||||
NEXUS_STT_MODEL=small # tiny|base|small|medium|large-v3 ИЛИ путь к CT2-модели
|
||||
NEXUS_STT_DEVICE=auto ; NEXUS_STT_COMPUTE=auto
|
||||
NEXUS_STT_LOCAL_ONLY=1 # оффлайн (без скачивания)
|
||||
HF_ENDPOINT=https://hf-mirror.com # зеркало HF в закрытых сетях
|
||||
# Admin
|
||||
NEXUS_ADMIN_HOST=127.0.0.1 ; NEXUS_ADMIN_PORT=4123
|
||||
# Proxy (опц.)
|
||||
NEXUS_HTTP_PROXY=http://proxy:8080
|
||||
```
|
||||
|
||||
Приоритет ключа: `<NAME>_API_KEY` (env) **>** зашифрованная запись в БД.
|
||||
|
||||
---
|
||||
|
||||
## 8. Команды
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # core → mcp-server → admin (по отдельности: build:core|build:mcp|build:admin)
|
||||
npm run start # запустить MCP-сервер (stdio)
|
||||
npm run admin # запустить админку (http://127.0.0.1:4123)
|
||||
```
|
||||
|
||||
MCP работает по stdio → **никогда не писать в stdout** кроме протокола; все логи идут в stderr.
|
||||
|
||||
### Локальный запуск админки в этой среде (фоновые процессы реапятся)
|
||||
|
||||
```bash
|
||||
tmux new-session -d -s nexusadmin -c /home/user/moder/mcp/NexusAI \
|
||||
"export NEXUS_MEDIA_DIR=/tmp/opencode/nexus-media NEXUS_MASTER_KEY=... NEXUS_ADMIN_PORT=4123; \
|
||||
exec node packages/admin/dist/server.js >/tmp/opencode/admin_live.log 2>&1"
|
||||
```
|
||||
|
||||
### Тестирование инструментов без полного MCP-хендшейка
|
||||
|
||||
Импортировать `buildContext` + `Handlers` из `dist/` и звать `handlers.handle('nexus_*', args)`.
|
||||
⚠️ Скрипт должен лежать **внутри** `packages/mcp-server/` (иначе не резолвятся node_modules workspace).
|
||||
Env для теста: `NEXUS_MEDIA_DIR`, `NEXUS_MASTER_KEY` (тот же, что при сохранении ключа), STT-переменные.
|
||||
|
||||
---
|
||||
|
||||
## 9. Важные особенности / подводные камни
|
||||
|
||||
- **stdout только для MCP.** Логи — в stderr (`createLogger`).
|
||||
- **Ключ мастер-шифрования обязателен** для чтения ключей из БД; иначе только ENV-ключи. Без ключа сервис не падает (грациозный фолбэк).
|
||||
- **NordRouter upload** ждёт `data:<mime>;base64,...` в поле `data`. MIME определяется по расширению — иначе `HTTP 415`.
|
||||
- **Kling i2v** не принимает прямую ссылку на сгенерированный контент — нужен URL из `nexus_media_upload` (`?raw=1`).
|
||||
- **Видео-модели** часто требуют полный набор полей (`duration`, `aspect_ratio`, `resolution`, ...), иначе `HTTP 400`.
|
||||
- **STT** не качает модель в закрытой сети (HF недоступен). Варианты: `HF_ENDPOINT`, локальная модель + `NEXUS_STT_LOCAL_ONLY=1` (модель `faster-whisper-*` можно взять с ModelScope).
|
||||
- **STT-транскрипт** сохраняется как `.md` в media-dir и виден в галерее/модалке (не путать с исходным аудио).
|
||||
- **Стоимость видео** может превышать `estUsd` (например, Kling 2.6 i2v: est `$0.34` → факт `$0.69`).
|
||||
- Некоторые модели бывают временно недоступны («Генерация временно недоступна — средства возвращены»).
|
||||
- **ID моделей NordRouter для chat/vision**: `google/gemini-3.1-flash-lite`, `openai/gpt-5.x` (НЕ `gpt-4o`).
|
||||
|
||||
---
|
||||
|
||||
## 10. Последние изменения
|
||||
|
||||
Свежие сверху. Держи актуальным.
|
||||
|
||||
- **`8bd2e89`** — STT пишет транскрипт в `.md` (виден в галерее/модалке); `ModelCatalog` (фоновый синк + fuzzy-резолв с дешёвым фолбэком и подсказками); UI: аудиоплеер (кнопка в карточке + плеер в модалке), просмотр текстовых `.md`, автообновление активной вкладки без перезагрузки; `/media` отдаёт корректный Content-Type.
|
||||
- **`b2f57cc`** — новый инструмент `nexus_vision`; фикс `nexus_media_upload` (MIME по расширению, чинит 415); `HttpClient` поддерживает FormData.
|
||||
- **`c65f910`** — STT больше не пишет путь исходного аудио как `filePath` (убрана битая ссылка в галерее).
|
||||
- **`1bc3746`** — Admin UX: светлая/тёмная тема, пагинация галереи, модалка + удаление, тип провайдера `openai`.
|
||||
- **`6ff2b27`** — фикс краша MCP при наличии ключа в БД без `NEXUS_MASTER_KEY`.
|
||||
- **`679e564`** — Admin (Phase 2): дашборд трат, галерея, управление провайдерами.
|
||||
|
||||
---
|
||||
|
||||
## 11. Куда добавлять новое
|
||||
|
||||
- **Новый провайдер** → адаптер в `core/src/providers/<name>/`, регистрация в `mcp-server/src/context.ts`, тип в `admin` `KNOWN_TYPES` при необходимости.
|
||||
- **Новый инструмент** → схема в `schemas.ts`, ветка в `Handlers.handle`, описание в `tools.ts`.
|
||||
- **Новое поле журнала** → миграция в `Journal.init`, обновить `GenerationRecord` и SELECT-колонки в `getById/list`.
|
||||
- **UI** → `public/index.html` (разметка/CSS) + `public/app.js` (логика). Сборки нет — правки применяются сразу.
|
||||
@@ -1,184 +0,0 @@
|
||||
# NexusAI — универсальный MCP-сервис для агентов
|
||||
|
||||
Единый MCP-сервер (TypeScript, stdio), дающий агентам провайдер-абстрагированный доступ к:
|
||||
|
||||
- **TTS** — озвучка текста
|
||||
- **STT** — распознавание речи (локально, faster-whisper)
|
||||
- **Изображения** — генерация и правка
|
||||
- **Видео** — text→video / image→video
|
||||
- **Музыка** — генерация треков
|
||||
- **Веб-поиск** — ответ с цитатами (search-augmented модели)
|
||||
|
||||
Первый провайдер — **NordRouter**. Архитектура расширяемая: RouterAI, GPTunnel, OpenRouter и облачные STT добавляются как новые адаптеры без изменения MCP-инструментов. Каждая операция логируется в SQLite (провайдер, модель, стоимость, статус, файл) — основа для будущей веб-админки.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
NexusAI/
|
||||
├── packages/
|
||||
│ ├── core/ # провайдеры, media-storage, journal, ключи, типы, registry
|
||||
│ ├── mcp-server/ # stdio MCP-сервер (10 инструментов)
|
||||
│ └── admin/ # веб-админка (Fastify + лёгкий фронт)
|
||||
├── sidecar/ # faster-whisper STT (PEP 723, запуск через uv)
|
||||
├── .env.example
|
||||
└── .opencode/opencode.json
|
||||
```
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
cd NexusAI
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
> `better-sqlite3` собирается нативно. Если сборка прервалась: `npm rebuild better-sqlite3`.
|
||||
|
||||
Для STT нужен [`uv`](https://docs.astral.sh/uv/) и `ffmpeg`. Зависимости whisper `uv` ставит автоматически при первом запуске.
|
||||
|
||||
## Подключение к opencode
|
||||
|
||||
`.opencode/opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"nexusai": {
|
||||
"type": "local",
|
||||
"command": ["node", "/абс/путь/NexusAI/packages/mcp-server/dist/index.js"],
|
||||
"env": {
|
||||
"NORDROUTER_API_KEY": "sk-...",
|
||||
"NEXUS_MEDIA_DIR": "/абс/путь/NexusAI/media-output",
|
||||
"NEXUS_STT_MODEL": "small"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Переменные окружения — см. `.env.example`.
|
||||
|
||||
## Инструменты
|
||||
|
||||
| Инструмент | Назначение |
|
||||
|---|---|
|
||||
| `nexus_tts` | текст → речь (`audio/elevenlabs-tts`, `audio/gemini-3.1-flash-tts`, …) |
|
||||
| `nexus_image` | генерация/правка изображений (`image/*`) |
|
||||
| `nexus_video` | видео из текста/фото (`video/*`) |
|
||||
| `nexus_music` | музыка (`music/*`) |
|
||||
| `nexus_stt` | речь → текст (локально) |
|
||||
| `nexus_web_search` | веб-поиск с цитатами (`perplexity/sonar*`, `search/*`) |
|
||||
| `nexus_media_models` | список моделей провайдера (id, тип, цена) |
|
||||
| `nexus_media_estimate` | оценка стоимости до запуска |
|
||||
| `nexus_media_upload` | загрузка исходника (фото/аудио/видео) |
|
||||
| `nexus_usage_stats` | траты и история из журнала |
|
||||
|
||||
### Единый формат вызова
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "nordrouter",
|
||||
"model": "audio/elevenlabs-tts",
|
||||
"input": { "text": "Привет, мир", "voice": "James · Husky, Engaging and Bold" },
|
||||
"output": { "save": true, "embed": false }
|
||||
}
|
||||
```
|
||||
|
||||
- `provider` опционален (default из `NEXUS_DEFAULT_PROVIDER`).
|
||||
- `input` пропускает любые модель-специфичные поля (`aspect_ratio`, `resolution`, `duration`, `sound`, …).
|
||||
- `output.save` — сохранять файл локально (по умолчанию да).
|
||||
- `output.embed` — встроить image/audio прямо в ответ MCP (с лимитом размера).
|
||||
|
||||
### Единый формат ответа
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "nordrouter",
|
||||
"capability": "tts",
|
||||
"model": "audio/elevenlabs-tts",
|
||||
"status": "done",
|
||||
"file_path": "/.../media-output/audio-...wav",
|
||||
"mime_type": "audio/wav",
|
||||
"result_url": "https://nordrouter.com/media/file/...",
|
||||
"result_url_2": null,
|
||||
"cost_usd": 0.0049
|
||||
}
|
||||
```
|
||||
|
||||
`result_url_2` заполняется для музыкальных моделей, отдающих два трека (Suno).
|
||||
|
||||
## Как это работает
|
||||
|
||||
- **Media** асинхронно: `POST /media/generate` → polling `GET /media/job/:id` → скачивание `result_url` в `NEXUS_MEDIA_DIR`. Таймаут зависит от типа (картинки — быстро, видео/музыка — до минут).
|
||||
- **Web search** через OpenAI-совместимый `/v1/chat/completions` с sonar/`search/*`; цитаты берутся из `message.annotations[].url_citation`.
|
||||
- **STT** локально: TS запускает `uv run sidecar/stt_server.py`; faster-whisper декодирует аудио (mp3/wav/ogg/m4a/webm) и возвращает `{text, language, segments}`.
|
||||
- **Журнал** SQLite (`nexus.sqlite` в media-dir) пишет каждую операцию; `nexus_usage_stats` агрегирует траты.
|
||||
- MCP работает по stdio — **все логи идут в stderr**.
|
||||
|
||||
## Ключи провайдеров
|
||||
|
||||
Ключи можно задавать двумя способами, с приоритетом **env → БД**:
|
||||
|
||||
1. **Env (приоритет)** — `NORDROUTER_API_KEY` (и `<NAME>_API_KEY` для других провайдеров). Просто для CI и локального запуска.
|
||||
2. **Зашифрованная БД** — управляется из будущей админки. Ключи хранятся в SQLite, зашифрованные **AES-256-GCM** на мастер-ключе `NEXUS_MASTER_KEY` (сам мастер-ключ — только в env, в БД его нет).
|
||||
|
||||
Свойства слоя:
|
||||
- В git/логи/БД сырой ключ не попадает; в UI показывается только маска `sk-…last4`.
|
||||
- Бэкап БД не раскрывает ключи (нужен мастер-ключ).
|
||||
- Ротация `NEXUS_MASTER_KEY` инвалидирует зашифрованные ключи — их нужно ввести заново.
|
||||
- Без `NEXUS_MASTER_KEY` работают только env-ключи (сервер предупреждает в stderr).
|
||||
|
||||
Программное управление (пока до админки) — через `@nexusai/core`:
|
||||
|
||||
```ts
|
||||
import { KeyCipher, ProvidersStore } from '@nexusai/core';
|
||||
const cipher = new KeyCipher(process.env.NEXUS_MASTER_KEY);
|
||||
const store = new ProvidersStore('./media-output/nexus.sqlite', cipher);
|
||||
store.upsert({ name: 'nordrouter', type: 'nordrouter', apiKey: 'sk-...', isDefault: true });
|
||||
store.setEnabled('nordrouter', true);
|
||||
store.listViews(); // masked view for UI
|
||||
```
|
||||
|
||||
Генерация мастер-ключа: `openssl rand -hex 32`.
|
||||
|
||||
## STT в ограниченных сетях
|
||||
|
||||
faster-whisper скачивает модель с HuggingFace. Если `huggingface.co` недоступен:
|
||||
|
||||
- задать зеркало: `HF_ENDPOINT=https://hf-mirror.com`
|
||||
- либо предзагрузить модель и указать путь: `NEXUS_STT_MODEL=/abs/model-dir` + `NEXUS_STT_LOCAL_ONLY=1`
|
||||
- либо кэш-директорию: `NEXUS_STT_DOWNLOAD_ROOT=./models`
|
||||
|
||||
## Веб-админка
|
||||
|
||||
Отдельный пакет `@nexusai/admin` (Fastify + одностраничный фронт) на общем `core`. Читает тот же SQLite и media-каталог.
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
NEXUS_MASTER_KEY=$(openssl rand -hex 32) \
|
||||
NEXUS_MEDIA_DIR=./media-output \
|
||||
npm run admin
|
||||
# → http://127.0.0.1:4123
|
||||
```
|
||||
|
||||
Возможности:
|
||||
- **Тема** — переключатель светлая/тёмная (сохраняется в localStorage).
|
||||
- **Потребление** — дашборд трат: суммарно, по провайдерам, по возможностям, по дням (из журнала).
|
||||
- **Галерея** — просмотр сгенерированного контента (изображения/аудио/видео), фильтр по возможности, пагинация 50/100/150/200, модальное окно на весь экран, удаление контента (файл + запись).
|
||||
- **Провайдеры** — добавить/включить/выключить, назначить default, ввести ключ (шифруется), **проверить ключ** реальным вызовом, типы `nordrouter` и `openai`. Ключи показываются только маской.
|
||||
|
||||
Переменные: `NEXUS_ADMIN_HOST` (default `127.0.0.1`), `NEXUS_ADMIN_PORT` (default `4123`), `NEXUS_MASTER_KEY` (для ключей).
|
||||
|
||||
> Важно: изменения провайдеров/ключей в админке подхватываются MCP-сервером **при рестарте** (hot-reload — в планах). По умолчанию админка слушает только localhost.
|
||||
|
||||
## Расширение: новый провайдер
|
||||
|
||||
1. Реализовать `MediaProvider` / `SearchProvider` / `SttProvider` из `@nexusai/core`.
|
||||
2. Зарегистрировать в `packages/mcp-server/src/context.ts`.
|
||||
|
||||
Инструменты и схемы менять не нужно — они провайдер-агностичны.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Фаза 2: веб-админка (отдельный пакет `admin`, Hono/Fastify + лёгкий фронт) на общем `core`: галерея контента, дашборд трат, управление провайдерами.
|
||||
- Доп. провайдеры: RouterAI, GPTunnel, OpenRouter; облачный STT.
|
||||
@@ -1,372 +0,0 @@
|
||||
# NexusAI — полное резюме тестов
|
||||
|
||||
**Дата:** 2026-08-18
|
||||
**Репозиторий:** `/home/user/moder/mcp/NexusAI`
|
||||
**Ветка:** `main`
|
||||
**Админка:** http://127.0.0.1:4123
|
||||
|
||||
---
|
||||
|
||||
## 1. Общая архитектура
|
||||
|
||||
Проект — универсальный MCP-сервис для агентов:
|
||||
|
||||
- `packages/core` — журнал SQLite, шифрование ключей, медиа-хранилище, провайдеры.
|
||||
- `packages/mcp-server` — MCP stdio-сервер с инструментами `nexus_*`.
|
||||
- `packages/admin` — Fastify web UI (дашборд, галерея, управление провайдерами).
|
||||
- `sidecar/stt_server.py` — локальный faster-whisper sidecar.
|
||||
|
||||
Провайдер по умолчанию — **NordRouter** (`https://nordrouter.com`).
|
||||
Ключи хранятся в SQLite, зашифрованные мастер-ключом `NEXUS_MASTER_KEY`.
|
||||
|
||||
### Переменные окружения, использованные в тестах
|
||||
|
||||
```bash
|
||||
NEXUS_MEDIA_DIR=/tmp/opencode/nexus-media
|
||||
NEXUS_MASTER_KEY=fixed-test-master-key-1234567890
|
||||
NEXUS_ADMIN_PORT=4123
|
||||
NEXUS_STT_MODEL=/tmp/opencode/whisper-tiny
|
||||
NEXUS_STT_LOCAL_ONLY=1
|
||||
NEXUS_STT_DEVICE=cpu
|
||||
HF_ENDPOINT=https://hf-mirror.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Запуск админки
|
||||
|
||||
Админ-сервер поднимался в detached `tmux`-сессии, потому что фоновые процессы в bash-инструменте реапятся.
|
||||
|
||||
```bash
|
||||
tmux kill-session -t nexusadmin 2>/dev/null; sleep 1
|
||||
tmux new-session -d -s nexusadmin -c /home/user/moder/mcp/NexusAI \
|
||||
"export NEXUS_MEDIA_DIR=/tmp/opencode/nexus-media NEXUS_MASTER_KEY=fixed-test-master-key-1234567890 NEXUS_ADMIN_PORT=4123; exec node packages/admin/dist/server.js >/tmp/opencode/admin_live.log 2>&1"
|
||||
```
|
||||
|
||||
**Проверка:**
|
||||
|
||||
```text
|
||||
HTTP:200
|
||||
[2026-08-18T02:59:15.659Z] INFO (admin:api) routes registered
|
||||
[2026-08-18T02:59:15.688Z] INFO (admin) NexusAI admin running at http://127.0.0.1:4123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Тест: веб-поиск «последние новости Барнаула»
|
||||
|
||||
### Скрипт
|
||||
|
||||
Временный: `packages/mcp-server/_search_test.cjs`
|
||||
|
||||
### Вызов
|
||||
|
||||
```javascript
|
||||
const { buildContext } = require('./dist/context.js');
|
||||
const ctx = buildContext();
|
||||
const p = ctx.registry.getSearch('nordrouter');
|
||||
const res = await p.search({ query: 'последние новости Барнаула', recency: 'day', maxResults: 5 });
|
||||
```
|
||||
|
||||
### Статус
|
||||
|
||||
✅ Успех.
|
||||
|
||||
### Сырой ответ ( answer )
|
||||
|
||||
```text
|
||||
Вот самые заметные новости Барнаула за последние сутки по доступным заголовкам и лентам:
|
||||
|
||||
- В Барнауле поддерживают подготовку к отопительному сезону...
|
||||
- В городе идут дожди...
|
||||
- В Барнауле можно бесплатно оформить медкарты...
|
||||
- Показательные выступления служебных собак...
|
||||
- На этой неделе в Барнауле можно сделать флюорографию...
|
||||
- ...
|
||||
```
|
||||
|
||||
### Citations
|
||||
|
||||
1. https://brl.mk.ru/news/
|
||||
2. https://ria.ru/location_Barnaul/
|
||||
3. https://www.alt.kp.ru/
|
||||
4. https://tolknews.ru/
|
||||
5. https://barnaul.org/news/
|
||||
|
||||
### Примечание
|
||||
|
||||
- Вызов был напрямую через адаптер, не через MCP-инструмент, поэтому в журнал не записался.
|
||||
- `costUsd` вернулся `undefined`: NordRouter не возвращает `usage.total_cost` для `perplexity/sonar-pro`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Тест: STT → TTS
|
||||
|
||||
### 4.1 Подготовка STT-модели
|
||||
|
||||
HuggingFace был недоступен (`Broken pipe`), поэтому модель `faster-whisper-tiny` скачана с зеркала ModelScope:
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/opencode/whisper-tiny
|
||||
cd /tmp/opencode/whisper-tiny
|
||||
for f in config.json configuration.json model.bin tokenizer.json vocabulary.txt README.md; do
|
||||
curl -sL -o "$f" "https://modelscope.cn/models/systran/faster-whisper-tiny/resolve/master/$f"
|
||||
done
|
||||
```
|
||||
|
||||
### 4.2 Скрипты
|
||||
|
||||
- `packages/mcp-server/_stt_test.cjs` — прямой вызов `local` STT.
|
||||
- `packages/mcp-server/_tts_test.cjs` — прямой вызов NordRouter TTS.
|
||||
|
||||
### 4.3 STT-результат
|
||||
|
||||
**Файл:** `/home/user/Музыка/Денис/audio_2025-08-21_12-21-30.ogg` (30K, Ogg)
|
||||
|
||||
```text
|
||||
--- text ---
|
||||
А то с ука в 30 градусной жарее весь трещал передел свистелблять. Каких там только звуков не было под нагрузкой блять.
|
||||
|
||||
language: ru | duration: 7.76
|
||||
```
|
||||
|
||||
### 4.4 TTS-результат
|
||||
|
||||
Модель: `audio/gemini-3.1-flash-tts`, голос `Zephyr`.
|
||||
|
||||
```text
|
||||
file_path: /tmp/opencode/nexus-media/audio-2026-08-18T03-25-00-156Z-v50yby.wav
|
||||
mime_type: audio/wav
|
||||
result_url: https://nordrouter.com/media/file/f987fdf8-5e2b-42c8-9883-6f18f8764452
|
||||
cost_usd: 0.00825
|
||||
```
|
||||
|
||||
### 4.5 Позже: полный цикл через MCP-инструменты
|
||||
|
||||
Использован `Handlers.handle('nexus_stt', ...)` и `Handlers.handle('nexus_tts', ...)`.
|
||||
Записи появились в журнале и галерее.
|
||||
|
||||
**Фикс:** `nexus_stt` больше не сохраняет путь к исходному аудио как `filePath`, иначе галерея пытается отдать файл из `/home/user/Музыка/...` через `/media/` и получает 404.
|
||||
|
||||
Коммит: `c65f910`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Тест: генерация изображений (GPT Image 2, Seedream 5.0 Pro, Gemini 3.1 Flash)
|
||||
|
||||
### Скрипты
|
||||
|
||||
- `packages/mcp-server/_image_models.cjs` — список image-моделей.
|
||||
- `packages/mcp-server/_image_gen.cjs` — генерация тремя моделями.
|
||||
|
||||
### Промпт
|
||||
|
||||
```text
|
||||
лисица кусает мухамор реалистичное фото изображение сделанное на проффисианальную камеру
|
||||
```
|
||||
|
||||
### Результаты
|
||||
|
||||
| Модель | Статус | Файл | Стоимость |
|
||||
|---|---|---|---|
|
||||
| `image/gpt-image-2` | ✅ done | `/tmp/opencode/nexus-media/image-2026-08-18T04-04-28-304Z-izb5rj.png` | `$0` |
|
||||
| `image/gemini-3.1-flash` | ✅ done | `/tmp/opencode/nexus-media/image-2026-08-18T04-04-48-360Z-3gloqp.png` | `$0.011534` |
|
||||
| `image/seedream-5.0-pro` | ❌ failed | — | средства не списаны |
|
||||
|
||||
### Ошибка Seedream
|
||||
|
||||
```text
|
||||
NexusError: Генерация временно недоступна — попробуйте ещё раз. Средства не списаны.
|
||||
```
|
||||
|
||||
Повторный запрос дал тот же результат.
|
||||
|
||||
### Сырой ответ (Gemini 3.1 Flash)
|
||||
|
||||
Изображение: лиса в зелёном лесу с красным мухомором во рту.
|
||||
|
||||
---
|
||||
|
||||
## 6. Тест: видео из картинки (Seedance 1.5 Pro)
|
||||
|
||||
### Скрипты
|
||||
|
||||
- `packages/mcp-server/_video_models.cjs`
|
||||
- `packages/mcp-server/_video_gen.cjs`
|
||||
|
||||
### Параметры
|
||||
|
||||
- Модель: `video/seedance-1.5-pro`
|
||||
- Входное изображение: `https://nordrouter.com/media/file/4da49a3c-3866-44af-8e6c-d9360f5ebab8`
|
||||
- Промпт: `A red fox gently bites and eats a bright red fly agaric mushroom...`
|
||||
- `duration=5`, `aspect_ratio=16:9`, `resolution=720p`, `generate_audio=true`
|
||||
|
||||
### Результат
|
||||
|
||||
```text
|
||||
status: done
|
||||
file_path: /tmp/opencode/nexus-media/video-2026-08-18T04-18-15-277Z-jb2tk9.mp4
|
||||
mime_type: video/mp4
|
||||
result_url: https://nordrouter.com/media/file/3dc0b9e7-5889-4f7b-b431-9087906380f9
|
||||
cost_usd: 0.21875
|
||||
```
|
||||
|
||||
Видео: 720p, 24 fps, AAC stereo — со звуком.
|
||||
|
||||
---
|
||||
|
||||
## 7. Тест: Kling 2.6 i2v с vision-описанием
|
||||
|
||||
### 7.1 Добавлен инструмент `nexus_vision`
|
||||
|
||||
Для полного цикла через NexusAI добавлен MCP-инструмент `nexus_vision`.
|
||||
|
||||
Изменения:
|
||||
|
||||
- `packages/mcp-server/src/schemas.ts` — `VisionSchema`
|
||||
- `packages/mcp-server/src/handlers.ts` — обработчик `vision`
|
||||
- `packages/mcp-server/src/tools.ts` — регистрация инструмента
|
||||
- `packages/core/src/shared/http.ts` — поддержка `FormData`
|
||||
- `packages/core/src/providers/nordrouter/media.ts` — определение MIME по расширению для upload
|
||||
|
||||
Дефолтная vision-модель: `google/gemini-3.1-flash-lite`.
|
||||
|
||||
### 7.2 Скрипты
|
||||
|
||||
- `packages/mcp-server/_video_models.cjs`
|
||||
- `packages/mcp-server/_vision_model_test.cjs` — подбор рабочей vision-модели
|
||||
- `packages/mcp-server/_upload_image.cjs`, `_upload_debug.cjs`, `_upload_json.cjs`, `_upload_dataurl.cjs` — отладка upload
|
||||
- `packages/mcp-server/_kling_test.cjs`, `_kling_retry.cjs`, `_kling_base64.cjs`, `_kling_neutral.cjs`, `_kling_standard.cjs`, `_kling26.cjs`, `_kling26_uploaded.cjs` — тесты Kling
|
||||
|
||||
### 7.3 Подбор vision-модели
|
||||
|
||||
Начальные попытки (`gpt-4o`, `openai/gpt-4o`, `gemini-3.1-flash`) вернули `unknown model`.
|
||||
Список `/v1/models` показал, что у NordRouter модели вида `google/gemini-3.1-flash-lite`, `openai/gpt-5.4-mini` и т.д.
|
||||
|
||||
Рабочая: `google/gemini-3.1-flash-lite`.
|
||||
|
||||
### 7.4 Vision-описание (сырой ответ)
|
||||
|
||||
```text
|
||||
Вот подробное описание сцены, структурированное как профессиональный промпт для генерации видео:
|
||||
|
||||
### Видео-промпт: «Лиса и мухомор в осеннем лесу»
|
||||
|
||||
**Общая сцена и композиция:**
|
||||
Кинематографичный кадр крупным планом, уровень глаз животного...
|
||||
|
||||
**Детали объектов:**
|
||||
* Лиса: густая пушистая шерсть рыжего цвета...
|
||||
* Гриб: классический мухомор...
|
||||
* Окружение: лесная подстилка, мох, опавшие листья...
|
||||
|
||||
**Освещение и цвета:**
|
||||
Мягкий естественный дневной свет...
|
||||
|
||||
**Технические параметры:**
|
||||
Стиль фотоработы National Geographic, 8k, макро-фотография, гиперреализм...
|
||||
Камера: f/2.8, объектив 85 мм...
|
||||
```
|
||||
|
||||
### 7.5 Проблемы с Kling
|
||||
|
||||
Сначала Kling 2.1/2.6 возвращали:
|
||||
|
||||
```text
|
||||
Не удалось сгенерировать. Средства возвращены на баланс.
|
||||
```
|
||||
|
||||
Причина: Kling i2v не принимал прямую ссылку на изображение, сгенерированное через `/media/generate`.
|
||||
Решение: загрузить исходник через `nexus_media_upload` и получить URL вида `https://nordrouter.com/media/file/up-...?raw=1`.
|
||||
|
||||
Также при upload был баг: MIME определялся как `application/octet-stream`, и NordRouter отвечал `HTTP 415`. Починено определением MIME по расширению файла.
|
||||
|
||||
### 7.6 Результат Kling 2.6 i2v
|
||||
|
||||
```text
|
||||
{
|
||||
"provider": "nordrouter",
|
||||
"capability": "video",
|
||||
"model": "video/kling-2.6-i2v",
|
||||
"status": "done",
|
||||
"file_path": "/tmp/opencode/nexus-media/video-2026-08-18T04-48-21-385Z-r5um7w.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"result_url": "https://nordrouter.com/media/file/3274f543-b954-4db5-9407-975cfa4de7cd",
|
||||
"cost_usd": 0.6875
|
||||
}
|
||||
```
|
||||
|
||||
- Длительность: 5.04 сек
|
||||
- Разрешение: 1440×1440, 24 fps
|
||||
- Аудио: AAC 44.1 kHz stereo
|
||||
- Размер: 13M
|
||||
|
||||
---
|
||||
|
||||
## 8. Ошибки и фиксы
|
||||
|
||||
| Проблема | Причина | Решение | Коммит |
|
||||
|---|---|---|---|
|
||||
| MCP падает без `NEXUS_MASTER_KEY` | `KeyCipher` пытался расшифровать пустой ключ | graceful fallback | `6ff2b27` |
|
||||
| STT-запись даёт битую ссылку в галерее | `filePath` указывал на исходный аудиофайл | `filePath = null` для STT | `c65f910` |
|
||||
| Upload возвращает 415 | MIME не определялся по расширению | таблица `mimeByExt` | `b2f57cc` |
|
||||
| Нет инструмента для vision | не было MCP-инструмента | добавлен `nexus_vision` | `b2f57cc` |
|
||||
| Kling i2v не принимал сгенерированный URL | требует upload-URL `?raw=1` | использован `nexus_media_upload` | — |
|
||||
|
||||
---
|
||||
|
||||
## 9. Итоговая статистика
|
||||
|
||||
```json
|
||||
{
|
||||
"totalCostUsd": 0.950597,
|
||||
"totalCount": 18,
|
||||
"byProvider": [
|
||||
{ "provider": "nordrouter", "count": 17, "costUsd": 0.950597 },
|
||||
{ "provider": "local", "count": 1, "costUsd": 0 }
|
||||
],
|
||||
"byCapability": [
|
||||
{ "capability": "video", "count": 2, "costUsd": 0.90625 },
|
||||
{ "capability": "tts", "count": 4, "costUsd": 0.022813 },
|
||||
{ "capability": "image", "count": 4, "costUsd": 0.021534 },
|
||||
{ "capability": "vision", "count": 1, "costUsd": 0 },
|
||||
{ "capability": "stt", "count": 1, "costUsd": 0 }
|
||||
],
|
||||
"byDay": [
|
||||
{ "day": "2026-08-18", "count": 14, "costUsd": 0.931534 },
|
||||
{ "day": "2026-08-17", "count": 4, "costUsd": 0.019063 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Коммиты
|
||||
|
||||
```text
|
||||
b2f57cc feat(mcp-server): add nexus_vision tool and fix upload MIME detection
|
||||
c65f910 fix(mcp-server): do not store source audio path as STT gallery file
|
||||
1bc3746 Admin UX: light/dark theme, gallery pagination, content modal + delete, openai type
|
||||
6ff2b27 Fix MCP crash when DB key exists without NEXUS_MASTER_KEY
|
||||
679e564 Add admin web UI (Phase 2): spend dashboard, media gallery, provider management
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Ключевые файлы
|
||||
|
||||
- `/tmp/opencode/nexus-media/image-2026-08-18T04-04-48-360Z-3gloqp.png` — Gemini 3.1 Flash (исходник для видео)
|
||||
- `/tmp/opencode/nexus-media/video-2026-08-18T04-18-15-277Z-jb2tk9.mp4` — Seedance 1.5 Pro i2v (со звуком)
|
||||
- `/tmp/opencode/nexus-media/video-2026-08-18T04-48-21-385Z-r5um7w.mp4` — Kling 2.6 i2v (со звуком)
|
||||
- `/tmp/opencode/whisper-tiny/` — локальная STT-модель
|
||||
- `/tmp/opencode/admin_live.log` — лог админки
|
||||
|
||||
---
|
||||
|
||||
## 12. Выводы
|
||||
|
||||
- ✅ MCP-инструменты работают в полном цикле и пишут в журнал/галерею.
|
||||
- ✅ Web search, STT, TTS, image, video, vision — все интегрированы.
|
||||
- ✅ Поддержка конкретных моделей (`model` в запросе) работает.
|
||||
- ⚠️ Некоторые модели временно недоступны (`seedream-5.0-pro`) или требуют upload (`kling-2.6-i2v`).
|
||||
- ⚠️ Расходы на видео значительно выше оценок: Kling 2.6 i2v `$0.6875` против estimated `$0.3438`.
|
||||
Generated
-2391
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "nexusai",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Universal MCP service for agents: TTS, STT, image/video/music generation and web search",
|
||||
"workspaces": [
|
||||
"packages/core",
|
||||
"packages/mcp-server",
|
||||
"packages/admin"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspace @nexusai/core && npm run build --workspace @nexusai/mcp-server && npm run build --workspace @nexusai/admin",
|
||||
"build:core": "npm run build --workspace @nexusai/core",
|
||||
"build:mcp": "npm run build --workspace @nexusai/mcp-server",
|
||||
"build:admin": "npm run build --workspace @nexusai/admin",
|
||||
"start": "node packages/mcp-server/dist/index.js",
|
||||
"admin": "node packages/admin/dist/server.js",
|
||||
"dev": "npm run build && node packages/mcp-server/dist/index.js"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "@nexusai/admin",
|
||||
"version": "0.1.0",
|
||||
"description": "NexusAI admin: web dashboard for content gallery, spend tracking and provider/key management",
|
||||
"main": "dist/server.js",
|
||||
"type": "commonjs",
|
||||
"bin": {
|
||||
"nexusai-admin": "dist/server.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/static": "^7.0.0",
|
||||
"@nexusai/core": "*",
|
||||
"fastify": "^4.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
|
||||
function toast(msg) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast';
|
||||
el.textContent = msg;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), 2600);
|
||||
}
|
||||
|
||||
async function api(path, opts) {
|
||||
const res = await fetch(path, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
|
||||
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function usd(n) { return (n == null ? 0 : n).toFixed(4); }
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||
|
||||
// --- Theme ---
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
const btn = $('#themeToggle');
|
||||
btn.textContent = theme === 'dark' ? '🌙 Тёмная' : '☀️ Светлая';
|
||||
localStorage.setItem('nexus-theme', theme);
|
||||
}
|
||||
$('#themeToggle').addEventListener('click', () => {
|
||||
const cur = document.documentElement.getAttribute('data-theme');
|
||||
applyTheme(cur === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
applyTheme(localStorage.getItem('nexus-theme') || 'dark');
|
||||
|
||||
// --- Tabs ---
|
||||
let activeTab = 'usage';
|
||||
function refreshActiveTab() {
|
||||
if (activeTab === 'usage') return loadStats();
|
||||
if (activeTab === 'gallery') return loadGallery();
|
||||
if (activeTab === 'providers') return loadProviders();
|
||||
}
|
||||
$$('nav button').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
$$('nav button').forEach((b) => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
$$('main section').forEach((s) => s.classList.add('hidden'));
|
||||
$(`#tab-${btn.dataset.tab}`).classList.remove('hidden');
|
||||
activeTab = btn.dataset.tab;
|
||||
refreshActiveTab();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Auto-refresh (no page reload) ---
|
||||
let autoTimer = null;
|
||||
function isModalOpen() { return $('#modalRoot').innerHTML.trim() !== ''; }
|
||||
function setupAutoRefresh() {
|
||||
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
|
||||
const on = $('#autoRefresh').checked;
|
||||
localStorage.setItem('nexus-autorefresh', on ? '1' : '0');
|
||||
localStorage.setItem('nexus-autorefresh-int', $('#autoRefreshInterval').value);
|
||||
if (!on) return;
|
||||
const ms = parseInt($('#autoRefreshInterval').value, 10) || 10000;
|
||||
autoTimer = setInterval(() => {
|
||||
// Don't disrupt the user while a modal is open or an audio is playing.
|
||||
if (isModalOpen()) return;
|
||||
if (document.hidden) return;
|
||||
refreshActiveTab();
|
||||
}, ms);
|
||||
}
|
||||
$('#autoRefresh').checked = localStorage.getItem('nexus-autorefresh') === '1';
|
||||
$('#autoRefreshInterval').value = localStorage.getItem('nexus-autorefresh-int') || '10000';
|
||||
$('#autoRefresh').addEventListener('change', setupAutoRefresh);
|
||||
$('#autoRefreshInterval').addEventListener('change', setupAutoRefresh);
|
||||
setupAutoRefresh();
|
||||
|
||||
// --- Usage ---
|
||||
async function loadStats() {
|
||||
const range = $('#statsRange').value;
|
||||
const q = range ? `?sinceDays=${range}` : '';
|
||||
const s = await api(`/api/stats${q}`);
|
||||
$('#statCards').innerHTML = `
|
||||
<div class="stat"><div class="n">$${usd(s.totalCostUsd)}</div><div class="l">Всего потрачено</div></div>
|
||||
<div class="stat"><div class="n">${s.totalCount}</div><div class="l">Всего операций</div></div>
|
||||
<div class="stat"><div class="n">${s.byProvider.length}</div><div class="l">Провайдеров</div></div>
|
||||
<div class="stat"><div class="n">${s.byCapability.length}</div><div class="l">Типов операций</div></div>`;
|
||||
const fill = (id, rows, key) => {
|
||||
$(`#${id} tbody`).innerHTML = rows.map((r) =>
|
||||
`<tr><td>${esc(r[key])}</td><td>${r.count}</td><td>$${usd(r.costUsd)}</td></tr>`).join('')
|
||||
|| '<tr><td colspan="3" class="muted">Нет данных</td></tr>';
|
||||
};
|
||||
fill('byProvider', s.byProvider, 'provider');
|
||||
fill('byCapability', s.byCapability, 'capability');
|
||||
fill('byDay', s.byDay, 'day');
|
||||
}
|
||||
$('#statsRange').addEventListener('change', loadStats);
|
||||
|
||||
// --- Gallery (with pagination) ---
|
||||
let galleryOffset = 0;
|
||||
let galleryLastCount = 0;
|
||||
|
||||
function pageSize() { return parseInt($('#pageSize').value, 10) || 50; }
|
||||
|
||||
async function loadGallery() {
|
||||
const cap = $('#galleryFilter').value;
|
||||
const limit = pageSize();
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(galleryOffset));
|
||||
if (cap) params.set('capability', cap);
|
||||
const { items } = await api(`/api/generations?${params.toString()}`);
|
||||
galleryLastCount = items.length;
|
||||
$('#gallery').innerHTML = items.map(renderTile).join('')
|
||||
|| '<div class="muted">Пока нет сгенерированного контента.</div>';
|
||||
const page = Math.floor(galleryOffset / limit) + 1;
|
||||
$('#pageInfo').textContent = `Страница ${page} · показано ${items.length}`;
|
||||
$('#prevPage').disabled = galleryOffset === 0;
|
||||
$('#nextPage').disabled = items.length < limit;
|
||||
|
||||
$$('[data-view]').forEach((el) => el.onclick = () => openModal(el.dataset.view));
|
||||
$$('[data-del]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); delGeneration(b.dataset.del); });
|
||||
$$('[data-play]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); openModal(b.dataset.play); });
|
||||
}
|
||||
|
||||
function isAudio(it) { return it.capability === 'tts' || it.capability === 'music'; }
|
||||
function isText(it) { return it.capability === 'stt' || (it.mediaUrl && /\.md$/i.test(it.mediaUrl)) || it.capability === 'vision'; }
|
||||
|
||||
function renderTile(it) {
|
||||
const cost = it.costUsd != null ? `$${usd(it.costUsd)}` : '';
|
||||
let media = '';
|
||||
if (it.mediaUrl) {
|
||||
if (it.capability === 'image') {
|
||||
media = `<div class="thumb" data-view="${it.id}"><img src="${it.mediaUrl}" loading="lazy" /></div>`;
|
||||
} else if (isAudio(it)) {
|
||||
media = `<div class="thumb audio-thumb" data-view="${it.id}">
|
||||
<button class="play-btn" data-play="${it.id}" data-src="${it.mediaUrl}" title="Прослушать">▶</button>
|
||||
</div>`;
|
||||
} else if (it.capability === 'video') {
|
||||
media = `<div class="thumb" data-view="${it.id}"><video style="width:100%;height:150px;background:#000" src="${it.mediaUrl}"></video></div>`;
|
||||
} else if (isText(it)) {
|
||||
media = `<div class="thumb text-thumb" data-view="${it.id}"><span class="doc-icon">📄</span></div>`;
|
||||
} else {
|
||||
media = `<a href="${it.mediaUrl}" target="_blank" style="display:block;padding:20px;text-align:center">Открыть файл</a>`;
|
||||
}
|
||||
} else {
|
||||
const status = it.status === 'failed' ? '⚠ ошибка' : 'нет локального файла';
|
||||
media = `<div class="thumb text-thumb ${it.status === 'failed' ? 'err' : ''}" data-view="${it.id}">
|
||||
<span class="muted">${status}</span></div>`;
|
||||
}
|
||||
return `<div class="tile">
|
||||
<button class="del" data-del="${it.id}" title="Удалить">✕</button>
|
||||
${media}
|
||||
<div class="meta">
|
||||
<div>${esc(it.capability)} · <span class="m">${cost}</span></div>
|
||||
<div class="m">${esc(it.model)}</div>
|
||||
<div class="m">${new Date(it.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
$('#galleryReload').addEventListener('click', () => { galleryOffset = 0; loadGallery(); });
|
||||
$('#galleryFilter').addEventListener('change', () => { galleryOffset = 0; loadGallery(); });
|
||||
$('#pageSize').addEventListener('change', () => { galleryOffset = 0; loadGallery(); });
|
||||
$('#prevPage').addEventListener('click', () => {
|
||||
galleryOffset = Math.max(0, galleryOffset - pageSize());
|
||||
loadGallery();
|
||||
});
|
||||
$('#nextPage').addEventListener('click', () => {
|
||||
if (galleryLastCount < pageSize()) return;
|
||||
galleryOffset += pageSize();
|
||||
loadGallery();
|
||||
});
|
||||
|
||||
// --- Modal viewer ---
|
||||
async function openModal(id) {
|
||||
let rec;
|
||||
try { rec = await api(`/api/generations/${encodeURIComponent(id)}`); }
|
||||
catch (e) { return toast('Ошибка: ' + e.message); }
|
||||
|
||||
let body = '';
|
||||
if (rec.mediaUrl && rec.capability === 'image') {
|
||||
body = `<img src="${rec.mediaUrl}" />`;
|
||||
} else if (rec.mediaUrl && rec.capability === 'video') {
|
||||
body = `<video controls autoplay src="${rec.mediaUrl}"></video>`;
|
||||
} else if (rec.mediaUrl && (rec.capability === 'tts' || rec.capability === 'music')) {
|
||||
body = `<div class="audio-player">
|
||||
<div class="audio-visual">🎵</div>
|
||||
<audio controls autoplay src="${rec.mediaUrl}"></audio>
|
||||
</div>`;
|
||||
} else if (rec.mediaUrl && /\.md$/i.test(rec.mediaUrl)) {
|
||||
// Transcript / text document — fetch and render.
|
||||
let text = '';
|
||||
try { text = await (await fetch(rec.mediaUrl)).text(); } catch { text = '(не удалось загрузить текст)'; }
|
||||
body = `<div class="doc-view">${esc(text)}</div>`;
|
||||
} else {
|
||||
body = `<div class="muted">Нет предпросмотра для этого типа.</div>`;
|
||||
}
|
||||
|
||||
const kv = `
|
||||
<div class="kv">Модель: ${esc(rec.model)}</div>
|
||||
<div class="kv">Провайдер: ${esc(rec.provider)} · Возможность: ${esc(rec.capability)}</div>
|
||||
<div class="kv">Стоимость: ${rec.costUsd != null ? '$' + usd(rec.costUsd) : '—'}</div>
|
||||
<div class="kv">Создано: ${new Date(rec.createdAt).toLocaleString()}</div>
|
||||
${rec.inputSummary ? `<div class="kv">Вход: ${esc(rec.inputSummary)}</div>` : ''}
|
||||
${rec.error ? `<div class="kv" style="color:var(--err)">Ошибка: ${esc(rec.error)}</div>` : ''}`;
|
||||
|
||||
$('#modalRoot').innerHTML = `
|
||||
<div class="modal-backdrop" id="modalBackdrop">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<strong>${esc(rec.capability)} · ${esc(rec.model)}</strong>
|
||||
<span class="spacer"></span>
|
||||
${rec.mediaUrl ? `<a class="ghost" href="${rec.mediaUrl}" target="_blank" style="text-decoration:none">Открыть в новой вкладке</a>` : ''}
|
||||
<button class="ghost danger" id="modalDel">Удалить</button>
|
||||
<button class="ghost" id="modalClose">Закрыть ✕</button>
|
||||
</div>
|
||||
<div class="modal-body">${body}${kv}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const close = () => { $('#modalRoot').innerHTML = ''; };
|
||||
$('#modalClose').onclick = close;
|
||||
$('#modalBackdrop').onclick = (e) => { if (e.target.id === 'modalBackdrop') close(); };
|
||||
document.addEventListener('keydown', function onEsc(e) {
|
||||
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onEsc); }
|
||||
});
|
||||
$('#modalDel').onclick = async () => {
|
||||
if (!confirm('Удалить этот контент?')) return;
|
||||
await delGeneration(id, true);
|
||||
close();
|
||||
};
|
||||
}
|
||||
|
||||
async function delGeneration(id, silent) {
|
||||
if (!silent && !confirm('Удалить этот контент?')) return;
|
||||
try {
|
||||
await api(`/api/generations/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
toast('Удалено');
|
||||
loadGallery();
|
||||
} catch (e) { toast('Ошибка: ' + e.message); }
|
||||
}
|
||||
|
||||
// --- Providers ---
|
||||
let CRYPTO_ENABLED = true;
|
||||
async function loadProviders() {
|
||||
const { providers, knownTypes, cryptoEnabled } = await api('/api/providers');
|
||||
CRYPTO_ENABLED = cryptoEnabled;
|
||||
$('#cryptoBadge').textContent = cryptoEnabled
|
||||
? 'Шифрование ключей: включено'
|
||||
: '⚠ NEXUS_MASTER_KEY не задан — ключи из БД недоступны';
|
||||
const typeSel = $('#pType');
|
||||
if (!typeSel.options.length) {
|
||||
typeSel.innerHTML = knownTypes.map((t) => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
|
||||
}
|
||||
$('#providersTable tbody').innerHTML = providers.map((p) => {
|
||||
const status = [
|
||||
p.isDefault ? '<span class="pill def">default</span>' : '',
|
||||
p.enabled ? '<span class="pill on">включён</span>' : '<span class="pill off">выключен</span>',
|
||||
].join(' ');
|
||||
return `<tr>
|
||||
<td>${esc(p.name)}</td>
|
||||
<td>${esc(p.type)}</td>
|
||||
<td>${p.hasKey ? `<code>${esc(p.keyHint || '••••')}</code>` : '<span class="muted">нет</span>'}</td>
|
||||
<td>${status}</td>
|
||||
<td>
|
||||
<button class="ghost" data-check="${esc(p.name)}">Проверить</button>
|
||||
<button class="ghost" data-toggle="${esc(p.name)}" data-en="${p.enabled}">${p.enabled ? 'Выключить' : 'Включить'}</button>
|
||||
<button class="ghost" data-default="${esc(p.name)}">Сделать default</button>
|
||||
<button class="ghost danger" data-del-provider="${esc(p.name)}">Удалить</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('') || '<tr><td colspan="5" class="muted">Нет провайдеров. Добавьте выше.</td></tr>';
|
||||
|
||||
$$('[data-check]').forEach((b) => b.onclick = () => checkProvider(b.dataset.check));
|
||||
$$('[data-toggle]').forEach((b) => b.onclick = () => toggleProvider(b.dataset.toggle, b.dataset.en !== 'true'));
|
||||
$$('[data-default]').forEach((b) => b.onclick = () => setDefault(b.dataset.default));
|
||||
$$('[data-del-provider]').forEach((b) => b.onclick = () => delProvider(b.dataset.delProvider));
|
||||
}
|
||||
|
||||
$('#pSave').addEventListener('click', async () => {
|
||||
try {
|
||||
const body = {
|
||||
name: $('#pName').value.trim(),
|
||||
type: $('#pType').value,
|
||||
baseUrl: $('#pBaseUrl').value.trim() || undefined,
|
||||
apiKey: $('#pKey').value.trim() || undefined,
|
||||
isDefault: $('#pDefault').value === 'true',
|
||||
enabled: $('#pEnabled').value === 'true',
|
||||
};
|
||||
if (!body.name) return toast('Укажите имя');
|
||||
if (body.apiKey && !CRYPTO_ENABLED) return toast('Нельзя сохранить ключ без NEXUS_MASTER_KEY');
|
||||
await api('/api/providers', { method: 'POST', body: JSON.stringify(body) });
|
||||
$('#pKey').value = '';
|
||||
toast('Сохранено');
|
||||
loadProviders();
|
||||
} catch (e) { toast('Ошибка: ' + e.message); }
|
||||
});
|
||||
|
||||
async function checkProvider(name) {
|
||||
try {
|
||||
toast('Проверяю ключ...');
|
||||
const r = await api(`/api/providers/${encodeURIComponent(name)}/check`, { method: 'POST', body: '{}' });
|
||||
toast(r.ok ? `OK · моделей: ${r.modelsCount}` : `Ошибка ключа: ${r.error}`);
|
||||
} catch (e) { toast('Ошибка: ' + e.message); }
|
||||
}
|
||||
async function toggleProvider(name, enabled) {
|
||||
try { await api(`/api/providers/${encodeURIComponent(name)}/enabled`, { method: 'POST', body: JSON.stringify({ enabled }) }); loadProviders(); }
|
||||
catch (e) { toast('Ошибка: ' + e.message); }
|
||||
}
|
||||
async function setDefault(name) {
|
||||
try { await api(`/api/providers/${encodeURIComponent(name)}/default`, { method: 'POST', body: '{}' }); loadProviders(); }
|
||||
catch (e) { toast('Ошибка: ' + e.message); }
|
||||
}
|
||||
async function delProvider(name) {
|
||||
if (!confirm(`Удалить провайдера "${name}"?`)) return;
|
||||
try { await api(`/api/providers/${encodeURIComponent(name)}`, { method: 'DELETE' }); loadProviders(); }
|
||||
catch (e) { toast('Ошибка: ' + e.message); }
|
||||
}
|
||||
|
||||
// initial
|
||||
loadStats();
|
||||
@@ -1,224 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>NexusAI Admin</title>
|
||||
<style>
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0f1115; --panel: #171a21; --panel2: #1f242e; --line: #2a2f3a;
|
||||
--text: #e6e9ef; --muted: #9aa4b2; --accent: #4f8cff; --ok: #35c46a;
|
||||
--warn: #f0b429; --err: #f26d6d; --shadow: rgba(0,0,0,.5);
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f4f6fa; --panel: #ffffff; --panel2: #f0f2f7; --line: #dce1ea;
|
||||
--text: #1a1f2b; --muted: #5c6675; --accent: #2f6fed; --ok: #1a9e51;
|
||||
--warn: #b3810f; --err: #d63d3d; --shadow: rgba(0,0,0,.18);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text);
|
||||
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
transition: background .15s, color .15s; }
|
||||
header { padding: 14px 20px; border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; gap: 16px; }
|
||||
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
||||
.badge { font-size: 12px; color: var(--muted); }
|
||||
header .spacer { flex: 1; }
|
||||
#themeToggle { background: var(--panel2); border: 1px solid var(--line); color: var(--text);
|
||||
border-radius: 8px; padding: 6px 12px; cursor: pointer; font: inherit; }
|
||||
nav { display: flex; gap: 6px; padding: 12px 20px 0; }
|
||||
nav button { background: var(--panel); color: var(--muted); border: 1px solid var(--line);
|
||||
padding: 8px 14px; border-radius: 8px 8px 0 0; cursor: pointer; }
|
||||
nav button.active { background: var(--panel2); color: var(--text); border-bottom-color: var(--panel2); }
|
||||
main { padding: 20px; }
|
||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 16px; margin-bottom: 16px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
|
||||
.stat { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
|
||||
.stat .n { font-size: 22px; font-weight: 700; }
|
||||
.stat .l { color: var(--muted); font-size: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); font-size: 13px; }
|
||||
th { color: var(--muted); font-weight: 500; }
|
||||
input, select { background: var(--panel2); color: var(--text); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 8px 10px; font: inherit; width: 100%; }
|
||||
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 4px; }
|
||||
button.act { background: var(--accent); color: #fff; border: none; border-radius: 8px;
|
||||
padding: 8px 14px; cursor: pointer; font: inherit; }
|
||||
button.ghost { background: transparent; color: var(--text); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 6px 10px; cursor: pointer; }
|
||||
button.danger { color: var(--err); border-color: var(--err); }
|
||||
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: end; }
|
||||
.row > div { flex: 1; min-width: 140px; }
|
||||
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; }
|
||||
.pill.on { background: rgba(53,196,106,.15); color: var(--ok); }
|
||||
.pill.off { background: rgba(242,109,109,.15); color: var(--err); }
|
||||
.pill.def { background: rgba(79,140,255,.15); color: var(--accent); }
|
||||
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }
|
||||
.tile { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px;
|
||||
overflow: hidden; position: relative; }
|
||||
.tile .thumb { cursor: pointer; }
|
||||
.tile img { width: 100%; height: 150px; object-fit: cover; display: block; background: #000; }
|
||||
.tile .meta { padding: 8px 10px; font-size: 12px; }
|
||||
.tile .meta .m { color: var(--muted); }
|
||||
.tile .del { position: absolute; top: 6px; right: 6px; background: var(--panel);
|
||||
border: 1px solid var(--line); color: var(--err); border-radius: 6px; padding: 2px 8px;
|
||||
cursor: pointer; font-size: 12px; opacity: .85; }
|
||||
.tile .del:hover { opacity: 1; }
|
||||
.pager { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.muted { color: var(--muted); }
|
||||
.hidden { display: none; }
|
||||
.toast { position: fixed; right: 16px; bottom: 16px; background: var(--panel2);
|
||||
border: 1px solid var(--line); padding: 10px 14px; border-radius: 8px; box-shadow: 0 6px 24px var(--shadow); }
|
||||
audio { width: 100%; }
|
||||
code { background: var(--panel2); padding: 1px 5px; border-radius: 4px; }
|
||||
/* Modal */
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.7);
|
||||
display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 50; }
|
||||
.modal { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||
max-width: 92vw; max-height: 92vh; overflow: auto; box-shadow: 0 20px 60px var(--shadow); }
|
||||
.modal-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--panel); }
|
||||
.modal-head .spacer { flex: 1; }
|
||||
.modal-body { padding: 16px; }
|
||||
.modal-body img, .modal-body video { max-width: 88vw; max-height: 72vh; display: block; margin: 0 auto; }
|
||||
.modal-body audio { width: 60vw; max-width: 600px; }
|
||||
.kv { color: var(--muted); font-size: 12px; margin-top: 10px; word-break: break-all; }
|
||||
/* Auto-refresh control */
|
||||
.autorefresh { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
.autorefresh select { width: auto; padding: 4px 6px; }
|
||||
.autorefresh input[type="checkbox"] { width: auto; }
|
||||
/* Audio card thumb + play button */
|
||||
.audio-thumb, .text-thumb { height: 150px; display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; background: linear-gradient(135deg, var(--panel2), var(--panel)); }
|
||||
.text-thumb.err { background: rgba(242,109,109,.08); }
|
||||
.play-btn { width: 56px; height: 56px; border-radius: 50%; border: none; cursor: pointer;
|
||||
background: var(--accent); color: #fff; font-size: 20px; line-height: 1;
|
||||
box-shadow: 0 6px 18px var(--shadow); transition: transform .1s; }
|
||||
.play-btn:hover { transform: scale(1.08); }
|
||||
.doc-icon { font-size: 42px; opacity: .8; }
|
||||
/* Modal audio player */
|
||||
.audio-player { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 20px; }
|
||||
.audio-visual { font-size: 64px; }
|
||||
/* Transcript / doc view */
|
||||
.doc-view { white-space: pre-wrap; word-break: break-word; font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 16px;
|
||||
max-width: 80vw; max-height: 70vh; overflow: auto; text-align: left; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>NexusAI Admin</h1>
|
||||
<span class="badge" id="cryptoBadge"></span>
|
||||
<span class="spacer"></span>
|
||||
<label class="autorefresh" title="Автообновление активной вкладки">
|
||||
<input type="checkbox" id="autoRefresh" /> Авто-обновление
|
||||
<select id="autoRefreshInterval">
|
||||
<option value="5000">5с</option>
|
||||
<option value="10000" selected>10с</option>
|
||||
<option value="30000">30с</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="themeToggle" title="Переключить тему">🌙 Тёмная</button>
|
||||
</header>
|
||||
<nav>
|
||||
<button data-tab="usage" class="active">Потребление</button>
|
||||
<button data-tab="gallery">Галерея</button>
|
||||
<button data-tab="providers">Провайдеры</button>
|
||||
</nav>
|
||||
<main>
|
||||
<section id="tab-usage">
|
||||
<div class="card">
|
||||
<div class="row" style="margin-bottom:12px">
|
||||
<div style="max-width:200px">
|
||||
<label>Период</label>
|
||||
<select id="statsRange">
|
||||
<option value="">Всё время</option>
|
||||
<option value="1">1 день</option>
|
||||
<option value="7" selected>7 дней</option>
|
||||
<option value="30">30 дней</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid" id="statCards"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0">По провайдерам</h3>
|
||||
<table id="byProvider"><thead><tr><th>Провайдер</th><th>Кол-во</th><th>Стоимость, $</th></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0">По возможностям</h3>
|
||||
<table id="byCapability"><thead><tr><th>Возможность</th><th>Кол-во</th><th>Стоимость, $</th></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0">По дням</h3>
|
||||
<table id="byDay"><thead><tr><th>День</th><th>Кол-во</th><th>Стоимость, $</th></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-gallery" class="hidden">
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div style="max-width:220px">
|
||||
<label>Фильтр по возможности</label>
|
||||
<select id="galleryFilter">
|
||||
<option value="">Все</option>
|
||||
<option value="image">image</option>
|
||||
<option value="tts">tts</option>
|
||||
<option value="music">music</option>
|
||||
<option value="video">video</option>
|
||||
<option value="stt">stt</option>
|
||||
<option value="vision">vision</option>
|
||||
<option value="search">search</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="max-width:180px">
|
||||
<label>Элементов на странице</label>
|
||||
<select id="pageSize">
|
||||
<option value="50" selected>50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="150">150</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="max-width:120px"><label> </label><button class="ghost" id="galleryReload">Обновить</button></div>
|
||||
</div>
|
||||
<div class="pager" style="margin-top:12px">
|
||||
<button class="ghost" id="prevPage">← Назад</button>
|
||||
<span class="muted" id="pageInfo">Страница 1</span>
|
||||
<button class="ghost" id="nextPage">Вперёд →</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallery" id="gallery"></div>
|
||||
</section>
|
||||
|
||||
<section id="tab-providers" class="hidden">
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0">Добавить / обновить провайдера</h3>
|
||||
<div class="row">
|
||||
<div><label>Имя</label><input id="pName" placeholder="nordrouter" /></div>
|
||||
<div><label>Тип</label><select id="pType"></select></div>
|
||||
<div><label>Base URL (опц.)</label><input id="pBaseUrl" placeholder="https://nordrouter.com" /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>API ключ (опц., шифруется)</label><input id="pKey" type="password" placeholder="sk-..." /></div>
|
||||
<div style="max-width:140px"><label>По умолчанию</label><select id="pDefault"><option value="false">Нет</option><option value="true">Да</option></select></div>
|
||||
<div style="max-width:140px"><label>Включён</label><select id="pEnabled"><option value="true">Да</option><option value="false">Нет</option></select></div>
|
||||
</div>
|
||||
<div style="margin-top:12px"><button class="act" id="pSave">Сохранить</button></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0">Провайдеры</h3>
|
||||
<table id="providersTable">
|
||||
<thead><tr><th>Имя</th><th>Тип</th><th>Ключ</th><th>Статус</th><th>Действия</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="modalRoot"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,29 +0,0 @@
|
||||
import {
|
||||
Journal,
|
||||
KeyCipher,
|
||||
ProvidersStore,
|
||||
loadConfig,
|
||||
createLogger,
|
||||
type NexusConfig,
|
||||
} from '@nexusai/core';
|
||||
|
||||
const log = createLogger('admin:context');
|
||||
|
||||
export interface AdminContext {
|
||||
config: NexusConfig;
|
||||
journal: Journal;
|
||||
store: ProvidersStore;
|
||||
cipher: KeyCipher;
|
||||
}
|
||||
|
||||
export function buildAdminContext(): AdminContext {
|
||||
const config = loadConfig();
|
||||
const journal = new Journal(config.dbPath);
|
||||
const cipher = new KeyCipher(config.masterKey);
|
||||
const store = new ProvidersStore(config.providersDbPath, cipher);
|
||||
|
||||
if (!cipher.enabled) {
|
||||
log.warn('NEXUS_MASTER_KEY not set — provider keys cannot be added/decrypted from the admin');
|
||||
}
|
||||
return { config, journal, store, cipher };
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
NexusError,
|
||||
NordRouterClient,
|
||||
NordRouterMediaProvider,
|
||||
MediaStorage,
|
||||
createLogger,
|
||||
} from '@nexusai/core';
|
||||
import type { AdminContext } from './context.js';
|
||||
|
||||
const log = createLogger('admin:api');
|
||||
|
||||
const KNOWN_TYPES = ['nordrouter', 'openai'];
|
||||
|
||||
function errorReply(err: unknown): { statusCode: number; body: { error: string; code: string } } {
|
||||
const e = NexusError.from(err);
|
||||
const statusCode =
|
||||
e.code === 'NOT_FOUND' ? 404 : e.code === 'CONFIG' || e.code === 'BAD_REQUEST' ? 400 : 500;
|
||||
return { statusCode, body: { error: e.message, code: e.code } };
|
||||
}
|
||||
|
||||
export function registerRoutes(app: FastifyInstance, ctx: AdminContext): void {
|
||||
// --- Providers ---
|
||||
app.get('/api/providers', async () => {
|
||||
return { providers: ctx.store.listViews(), knownTypes: KNOWN_TYPES, cryptoEnabled: ctx.cipher.enabled };
|
||||
});
|
||||
|
||||
app.post('/api/providers', async (req, reply) => {
|
||||
try {
|
||||
const body = req.body as {
|
||||
name?: string;
|
||||
type?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
enabled?: boolean;
|
||||
isDefault?: boolean;
|
||||
};
|
||||
if (!body?.name || !body?.type) {
|
||||
throw new NexusError('BAD_REQUEST', 'name and type are required');
|
||||
}
|
||||
if (body.apiKey && !ctx.cipher.enabled) {
|
||||
throw new NexusError('CONFIG', 'NEXUS_MASTER_KEY must be set to store an API key');
|
||||
}
|
||||
ctx.store.upsert({
|
||||
name: body.name,
|
||||
type: body.type,
|
||||
baseUrl: body.baseUrl ?? null,
|
||||
apiKey: body.apiKey,
|
||||
enabled: body.enabled,
|
||||
isDefault: body.isDefault,
|
||||
});
|
||||
return { ok: true, provider: ctx.store.get(body.name) };
|
||||
} catch (err) {
|
||||
const { statusCode, body } = errorReply(err);
|
||||
return reply.code(statusCode).send(body);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/providers/:name/enabled', async (req, reply) => {
|
||||
try {
|
||||
const { name } = req.params as { name: string };
|
||||
const { enabled } = req.body as { enabled: boolean };
|
||||
ctx.store.setEnabled(name, enabled);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const { statusCode, body } = errorReply(err);
|
||||
return reply.code(statusCode).send(body);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/providers/:name/default', async (req, reply) => {
|
||||
try {
|
||||
const { name } = req.params as { name: string };
|
||||
ctx.store.setDefault(name);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const { statusCode, body } = errorReply(err);
|
||||
return reply.code(statusCode).send(body);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/providers/:name', async (req, reply) => {
|
||||
try {
|
||||
const { name } = req.params as { name: string };
|
||||
ctx.store.remove(name);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const { statusCode, body } = errorReply(err);
|
||||
return reply.code(statusCode).send(body);
|
||||
}
|
||||
});
|
||||
|
||||
// Validate a key: uses provided key, or the stored one, or env fallback.
|
||||
app.post('/api/providers/:name/check', async (req, reply) => {
|
||||
try {
|
||||
const { name } = req.params as { name: string };
|
||||
const body = (req.body ?? {}) as { apiKey?: string; baseUrl?: string };
|
||||
const row = ctx.store.get(name);
|
||||
const type = row?.type ?? name;
|
||||
if (!KNOWN_TYPES.includes(type)) {
|
||||
throw new NexusError('UNSUPPORTED', `Key check not implemented for type "${type}"`);
|
||||
}
|
||||
const apiKey =
|
||||
body.apiKey ||
|
||||
ctx.store.getApiKey(name) ||
|
||||
process.env[`${name.toUpperCase()}_API_KEY`];
|
||||
if (!apiKey) throw new NexusError('CONFIG', 'No API key available to check');
|
||||
const baseUrl = body.baseUrl || row?.baseUrl || 'https://nordrouter.com';
|
||||
|
||||
const client = new NordRouterClient(apiKey, baseUrl);
|
||||
const provider = new NordRouterMediaProvider(client, { save: false });
|
||||
const result = await provider.checkKey();
|
||||
return result;
|
||||
} catch (err) {
|
||||
const { statusCode, body } = errorReply(err);
|
||||
return reply.code(statusCode).send(body);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Usage / stats ---
|
||||
app.get('/api/stats', async (req) => {
|
||||
const q = req.query as { sinceDays?: string };
|
||||
const sinceDays = q.sinceDays ? Number.parseInt(q.sinceDays, 10) : undefined;
|
||||
return ctx.journal.stats(Number.isFinite(sinceDays as number) ? sinceDays : undefined);
|
||||
});
|
||||
|
||||
// --- Generations (gallery) ---
|
||||
app.get('/api/generations', async (req) => {
|
||||
const q = req.query as { limit?: string; offset?: string; capability?: string };
|
||||
const limit = Math.min(200, q.limit ? Number.parseInt(q.limit, 10) : 50);
|
||||
const offset = q.offset ? Number.parseInt(q.offset, 10) : 0;
|
||||
const items = ctx.journal.list(limit, offset, q.capability);
|
||||
// Add a media URL for locally-saved files that still exist.
|
||||
const withUrls = items.map((it) => ({
|
||||
...it,
|
||||
mediaUrl: it.filePath && fs.existsSync(it.filePath) ? `/media/${path.basename(it.filePath)}` : null,
|
||||
}));
|
||||
return { items: withUrls, limit, offset };
|
||||
});
|
||||
|
||||
app.get('/api/generations/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const rec = ctx.journal.getById(id);
|
||||
if (!rec) return reply.code(404).send({ error: 'not found', code: 'NOT_FOUND' });
|
||||
return {
|
||||
...rec,
|
||||
mediaUrl: rec.filePath && fs.existsSync(rec.filePath) ? `/media/${path.basename(rec.filePath)}` : null,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/generations/:id', async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id: string };
|
||||
const rec = ctx.journal.getById(id);
|
||||
if (!rec) return reply.code(404).send({ error: 'not found', code: 'NOT_FOUND' });
|
||||
|
||||
let fileRemoved = false;
|
||||
if (rec.filePath) {
|
||||
const mediaDir = path.resolve(ctx.config.mediaDir);
|
||||
const resolved = path.resolve(rec.filePath);
|
||||
// Only delete files that live inside the media dir.
|
||||
if (resolved.startsWith(mediaDir + path.sep) && fs.existsSync(resolved)) {
|
||||
try {
|
||||
fs.unlinkSync(resolved);
|
||||
fileRemoved = true;
|
||||
} catch (e) {
|
||||
log.warn('failed to delete file', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
const recordRemoved = ctx.journal.deleteById(id);
|
||||
return { ok: true, fileRemoved, recordRemoved };
|
||||
} catch (err) {
|
||||
const { statusCode, body } = errorReply(err);
|
||||
return reply.code(statusCode).send(body);
|
||||
}
|
||||
});
|
||||
|
||||
log.info('routes registered');
|
||||
}
|
||||
|
||||
/** Guard: only serve files that live inside the media dir. */
|
||||
export function isInsideMediaDir(mediaDir: string, requested: string): boolean {
|
||||
const resolved = path.resolve(mediaDir, requested);
|
||||
const rel = path.relative(path.resolve(mediaDir), resolved);
|
||||
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
export function ensureStorage(mediaDir: string): MediaStorage {
|
||||
return new MediaStorage(mediaDir);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import Fastify from 'fastify';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { createLogger } from '@nexusai/core';
|
||||
import { buildAdminContext } from './context.js';
|
||||
import { registerRoutes } from './routes.js';
|
||||
|
||||
const log = createLogger('admin');
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const ctx = buildAdminContext();
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
registerRoutes(app, ctx);
|
||||
|
||||
// Serve generated media files from the configured media dir.
|
||||
const mediaDir = path.resolve(ctx.config.mediaDir);
|
||||
app.get('/media/:file', async (req, reply) => {
|
||||
const { file } = req.params as { file: string };
|
||||
// Prevent path traversal: only a bare filename is allowed.
|
||||
if (file.includes('/') || file.includes('..') || path.isAbsolute(file)) {
|
||||
return reply.code(400).send({ error: 'invalid file name' });
|
||||
}
|
||||
const full = path.join(mediaDir, file);
|
||||
if (!full.startsWith(mediaDir + path.sep) || !fs.existsSync(full)) {
|
||||
return reply.code(404).send({ error: 'not found' });
|
||||
}
|
||||
const ext = path.extname(full).toLowerCase();
|
||||
const CT: Record<string, string> = {
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp', '.gif': 'image/gif',
|
||||
'.mp4': 'video/mp4', '.webm': 'video/webm',
|
||||
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4',
|
||||
'.md': 'text/markdown; charset=utf-8', '.txt': 'text/plain; charset=utf-8', '.json': 'application/json',
|
||||
};
|
||||
if (CT[ext]) reply.header('Content-Type', CT[ext]);
|
||||
const stream = fs.createReadStream(full);
|
||||
reply.header('Cache-Control', 'private, max-age=60');
|
||||
return reply.send(stream);
|
||||
});
|
||||
|
||||
// Serve the static frontend (built into ./public next to dist).
|
||||
const publicDir = path.resolve(__dirname, '../public');
|
||||
if (fs.existsSync(publicDir)) {
|
||||
await app.register(fastifyStatic, { root: publicDir, prefix: '/' });
|
||||
} else {
|
||||
log.warn(`public dir not found at ${publicDir}; UI will not be served`);
|
||||
}
|
||||
|
||||
const port = Number.parseInt(process.env.NEXUS_ADMIN_PORT || '4123', 10);
|
||||
const host = process.env.NEXUS_ADMIN_HOST || '127.0.0.1';
|
||||
|
||||
try {
|
||||
await app.listen({ port, host });
|
||||
log.info(`NexusAI admin running at http://${host}:${port}`);
|
||||
log.info(`media dir: ${mediaDir}`);
|
||||
if (!ctx.cipher.enabled) {
|
||||
log.warn('crypto disabled (NEXUS_MASTER_KEY unset): cannot add/decrypt provider keys');
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('failed to start admin', err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "@nexusai/core",
|
||||
"version": "0.1.0",
|
||||
"description": "NexusAI shared core: providers, media storage, journal, types",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"undici": "^6.27.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface NordRouterConfig {
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface SttConfig {
|
||||
/** Path to python interpreter or "uv" launcher command. */
|
||||
pythonCmd: string;
|
||||
sidecarPath?: string;
|
||||
model: string; // whisper size: tiny/base/small/medium/large-v3
|
||||
device: string; // cpu / cuda / auto
|
||||
computeType: string; // int8 / float16 / auto
|
||||
}
|
||||
|
||||
export interface NexusConfig {
|
||||
defaultProvider: string;
|
||||
mediaDir: string;
|
||||
dbPath: string;
|
||||
/** SQLite path for the encrypted providers store (defaults to same as dbPath). */
|
||||
providersDbPath: string;
|
||||
/** Master secret for encrypting stored API keys (env NEXUS_MASTER_KEY). */
|
||||
masterKey?: string;
|
||||
embedMaxBytes: number;
|
||||
nordrouter: NordRouterConfig;
|
||||
stt: SttConfig;
|
||||
}
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
export function loadConfig(): NexusConfig {
|
||||
const mediaDir =
|
||||
process.env.NEXUS_MEDIA_DIR || path.join(process.cwd(), 'media-output');
|
||||
const dbPath = process.env.NEXUS_DB_PATH || path.join(mediaDir, 'nexus.sqlite');
|
||||
|
||||
return {
|
||||
defaultProvider: process.env.NEXUS_DEFAULT_PROVIDER || 'nordrouter',
|
||||
mediaDir,
|
||||
dbPath,
|
||||
providersDbPath: process.env.NEXUS_PROVIDERS_DB_PATH || dbPath,
|
||||
masterKey: process.env.NEXUS_MASTER_KEY,
|
||||
embedMaxBytes: envInt('NEXUS_EMBED_MAX_BYTES', 10 * 1024 * 1024),
|
||||
nordrouter: {
|
||||
apiKey: process.env.NORDROUTER_API_KEY,
|
||||
baseUrl: (process.env.NORDROUTER_BASE_URL || 'https://nordrouter.com').replace(/\/$/, ''),
|
||||
},
|
||||
stt: {
|
||||
pythonCmd: process.env.NEXUS_STT_PYTHON || 'uv',
|
||||
sidecarPath: process.env.NEXUS_STT_SIDECAR,
|
||||
model: process.env.NEXUS_STT_MODEL || 'small',
|
||||
device: process.env.NEXUS_STT_DEVICE || 'auto',
|
||||
computeType: process.env.NEXUS_STT_COMPUTE || 'auto',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const HOME_DIR = os.homedir();
|
||||
@@ -1,158 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from '../shared/logger.js';
|
||||
|
||||
const log = createLogger('journal');
|
||||
|
||||
export interface GenerationRecord {
|
||||
id: string;
|
||||
provider: string;
|
||||
capability: string;
|
||||
model: string;
|
||||
inputSummary: string;
|
||||
status: 'done' | 'failed';
|
||||
costUsd: number | null;
|
||||
filePath: string | null;
|
||||
resultUrl: string | null;
|
||||
error: string | null;
|
||||
durationMs: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UsageStats {
|
||||
totalCostUsd: number;
|
||||
totalCount: number;
|
||||
byProvider: Array<{ provider: string; count: number; costUsd: number }>;
|
||||
byCapability: Array<{ capability: string; count: number; costUsd: number }>;
|
||||
byDay: Array<{ day: string; count: number; costUsd: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite-backed journal of every generation. Enables usage stats now and a
|
||||
* shared data source for the future admin package.
|
||||
*/
|
||||
export class Journal {
|
||||
private readonly db: Database.Database;
|
||||
|
||||
constructor(dbPath: string) {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
this.db = new Database(dbPath);
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS generations (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
capability TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
input_summary TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
cost_usd REAL,
|
||||
file_path TEXT,
|
||||
result_url TEXT,
|
||||
error TEXT,
|
||||
duration_ms INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_gen_created ON generations(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_gen_provider ON generations(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_gen_capability ON generations(capability);
|
||||
`);
|
||||
}
|
||||
|
||||
record(rec: GenerationRecord): void {
|
||||
try {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO generations
|
||||
(id, provider, capability, model, input_summary, status, cost_usd, file_path, result_url, error, duration_ms, created_at)
|
||||
VALUES (@id, @provider, @capability, @model, @inputSummary, @status, @costUsd, @filePath, @resultUrl, @error, @durationMs, @createdAt)`
|
||||
)
|
||||
.run(rec);
|
||||
} catch (err) {
|
||||
// Journaling must never break a successful generation.
|
||||
log.warn('failed to record generation', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
getById(id: string): GenerationRecord | undefined {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, provider, capability, model, input_summary as inputSummary, status,
|
||||
cost_usd as costUsd, file_path as filePath, result_url as resultUrl,
|
||||
error, duration_ms as durationMs, created_at as createdAt
|
||||
FROM generations WHERE id = ?`
|
||||
)
|
||||
.get(id);
|
||||
return row as GenerationRecord | undefined;
|
||||
}
|
||||
|
||||
list(limit = 50, offset = 0, capability?: string): GenerationRecord[] {
|
||||
const where = capability ? 'WHERE capability = ?' : '';
|
||||
const params = capability ? [capability, limit, offset] : [limit, offset];
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, provider, capability, model, input_summary as inputSummary, status,
|
||||
cost_usd as costUsd, file_path as filePath, result_url as resultUrl,
|
||||
error, duration_ms as durationMs, created_at as createdAt
|
||||
FROM generations ${where}
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(...params);
|
||||
return rows as GenerationRecord[];
|
||||
}
|
||||
|
||||
deleteById(id: string): boolean {
|
||||
const r = this.db.prepare('DELETE FROM generations WHERE id = ?').run(id);
|
||||
return r.changes > 0;
|
||||
}
|
||||
|
||||
stats(sinceDays?: number): UsageStats {
|
||||
const filter = sinceDays
|
||||
? `WHERE created_at >= datetime('now', '-${Math.max(1, Math.floor(sinceDays))} days')`
|
||||
: '';
|
||||
|
||||
const total = this.db
|
||||
.prepare(
|
||||
`SELECT COALESCE(SUM(cost_usd),0) as cost, COUNT(*) as cnt FROM generations ${filter}`
|
||||
)
|
||||
.get() as { cost: number; cnt: number };
|
||||
|
||||
const byProvider = this.db
|
||||
.prepare(
|
||||
`SELECT provider, COUNT(*) as count, COALESCE(SUM(cost_usd),0) as costUsd
|
||||
FROM generations ${filter} GROUP BY provider ORDER BY costUsd DESC`
|
||||
)
|
||||
.all() as UsageStats['byProvider'];
|
||||
|
||||
const byCapability = this.db
|
||||
.prepare(
|
||||
`SELECT capability, COUNT(*) as count, COALESCE(SUM(cost_usd),0) as costUsd
|
||||
FROM generations ${filter} GROUP BY capability ORDER BY costUsd DESC`
|
||||
)
|
||||
.all() as UsageStats['byCapability'];
|
||||
|
||||
const byDay = this.db
|
||||
.prepare(
|
||||
`SELECT substr(created_at,1,10) as day, COUNT(*) as count, COALESCE(SUM(cost_usd),0) as costUsd
|
||||
FROM generations ${filter} GROUP BY day ORDER BY day DESC LIMIT 30`
|
||||
)
|
||||
.all() as UsageStats['byDay'];
|
||||
|
||||
return {
|
||||
totalCostUsd: total.cost,
|
||||
totalCount: total.cnt,
|
||||
byProvider,
|
||||
byCapability,
|
||||
byDay,
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { NexusError } from '../shared/errors.js';
|
||||
import { createLogger } from '../shared/logger.js';
|
||||
import { KeyCipher, maskSecret } from '../security/crypto.js';
|
||||
|
||||
const log = createLogger('providers-store');
|
||||
|
||||
export interface ProviderRow {
|
||||
name: string;
|
||||
type: string; // e.g. "nordrouter" | "routerai" | "openrouter"
|
||||
baseUrl: string | null;
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Public view: never exposes the raw key, only a masked hint. */
|
||||
export interface ProviderView extends ProviderRow {
|
||||
hasKey: boolean;
|
||||
keyHint: string | null;
|
||||
}
|
||||
|
||||
export interface UpsertProviderInput {
|
||||
name: string;
|
||||
type: string;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string; // plaintext; will be encrypted at rest
|
||||
enabled?: boolean;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite-backed provider registry with encrypted API keys.
|
||||
* Managed by the admin (Phase 2); read by the MCP server at startup.
|
||||
*/
|
||||
export class ProvidersStore {
|
||||
private readonly db: Database.Database;
|
||||
|
||||
constructor(dbPath: string, private readonly cipher: KeyCipher) {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
this.db = new Database(dbPath);
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS providers (
|
||||
name TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
base_url TEXT,
|
||||
api_key_enc TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
upsert(input: UpsertProviderInput): void {
|
||||
const now = new Date().toISOString();
|
||||
const encKey = input.apiKey !== undefined ? this.cipher.encrypt(input.apiKey) : undefined;
|
||||
|
||||
const existing = this.db.prepare('SELECT name FROM providers WHERE name = ?').get(input.name);
|
||||
|
||||
if (existing) {
|
||||
// Update only provided fields; keep existing key if not supplied.
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE providers SET
|
||||
type = @type,
|
||||
base_url = COALESCE(@baseUrl, base_url),
|
||||
api_key_enc = COALESCE(@encKey, api_key_enc),
|
||||
enabled = @enabled,
|
||||
is_default = @isDefault,
|
||||
updated_at = @now
|
||||
WHERE name = @name`
|
||||
)
|
||||
.run({
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
baseUrl: input.baseUrl ?? null,
|
||||
encKey: encKey ?? null,
|
||||
enabled: input.enabled === false ? 0 : 1,
|
||||
isDefault: input.isDefault ? 1 : 0,
|
||||
now,
|
||||
});
|
||||
} else {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO providers (name, type, base_url, api_key_enc, enabled, is_default, created_at, updated_at)
|
||||
VALUES (@name, @type, @baseUrl, @encKey, @enabled, @isDefault, @now, @now)`
|
||||
)
|
||||
.run({
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
baseUrl: input.baseUrl ?? null,
|
||||
encKey: encKey ?? null,
|
||||
enabled: input.enabled === false ? 0 : 1,
|
||||
isDefault: input.isDefault ? 1 : 0,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.isDefault) this.setDefault(input.name);
|
||||
log.info(`provider upserted: ${input.name}`);
|
||||
}
|
||||
|
||||
setEnabled(name: string, enabled: boolean): void {
|
||||
const r = this.db
|
||||
.prepare('UPDATE providers SET enabled = ?, updated_at = ? WHERE name = ?')
|
||||
.run(enabled ? 1 : 0, new Date().toISOString(), name);
|
||||
if (r.changes === 0) throw new NexusError('NOT_FOUND', `Provider not found: ${name}`);
|
||||
}
|
||||
|
||||
setDefault(name: string): void {
|
||||
const exists = this.db.prepare('SELECT 1 FROM providers WHERE name = ?').get(name);
|
||||
if (!exists) throw new NexusError('NOT_FOUND', `Provider not found: ${name}`);
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db.prepare('UPDATE providers SET is_default = 0').run();
|
||||
this.db
|
||||
.prepare('UPDATE providers SET is_default = 1, updated_at = ? WHERE name = ?')
|
||||
.run(new Date().toISOString(), name);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
remove(name: string): void {
|
||||
this.db.prepare('DELETE FROM providers WHERE name = ?').run(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the decrypted API key for a provider, or undefined.
|
||||
* Returns undefined (instead of throwing) when no master key is configured,
|
||||
* so callers can gracefully fall back to env keys.
|
||||
*/
|
||||
getApiKey(name: string): string | undefined {
|
||||
const row = this.db.prepare('SELECT api_key_enc FROM providers WHERE name = ?').get(name) as
|
||||
| { api_key_enc: string | null }
|
||||
| undefined;
|
||||
if (!row?.api_key_enc) return undefined;
|
||||
if (!this.cipher.enabled) return undefined;
|
||||
return this.cipher.decrypt(row.api_key_enc);
|
||||
}
|
||||
|
||||
get(name: string): ProviderRow | undefined {
|
||||
const row = this.db.prepare('SELECT * FROM providers WHERE name = ?').get(name) as any;
|
||||
return row ? this.mapRow(row) : undefined;
|
||||
}
|
||||
|
||||
list(): ProviderRow[] {
|
||||
const rows = this.db.prepare('SELECT * FROM providers ORDER BY name').all() as any[];
|
||||
return rows.map((r) => this.mapRow(r));
|
||||
}
|
||||
|
||||
/** Admin-safe listing: masked key hint, never the raw key. */
|
||||
listViews(): ProviderView[] {
|
||||
const rows = this.db.prepare('SELECT * FROM providers ORDER BY name').all() as any[];
|
||||
return rows.map((r) => {
|
||||
const base = this.mapRow(r);
|
||||
let keyHint: string | null = null;
|
||||
if (r.api_key_enc) {
|
||||
try {
|
||||
keyHint = maskSecret(this.cipher.decrypt(r.api_key_enc));
|
||||
} catch {
|
||||
keyHint = '(undecryptable)';
|
||||
}
|
||||
}
|
||||
return { ...base, hasKey: Boolean(r.api_key_enc), keyHint };
|
||||
});
|
||||
}
|
||||
|
||||
private mapRow(r: any): ProviderRow {
|
||||
return {
|
||||
name: r.name,
|
||||
type: r.type,
|
||||
baseUrl: r.base_url,
|
||||
enabled: Boolean(r.enabled),
|
||||
isDefault: Boolean(r.is_default),
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export * from './config.js';
|
||||
export * from './shared/errors.js';
|
||||
export * from './shared/logger.js';
|
||||
export * from './shared/http.js';
|
||||
export * from './shared/polling.js';
|
||||
export * from './db/journal.js';
|
||||
export * from './db/providers-store.js';
|
||||
export * from './security/crypto.js';
|
||||
export * from './security/resolver.js';
|
||||
export * from './media/storage.js';
|
||||
export * from './media/content.js';
|
||||
export * from './providers/types.js';
|
||||
export * from './providers/registry.js';
|
||||
export * from './providers/model-catalog.js';
|
||||
export * from './providers/nordrouter/index.js';
|
||||
export * from './providers/local-stt/whisper.js';
|
||||
@@ -1,36 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import { extToMime } from './storage.js';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* MCP content block for embedding media directly in a tool result.
|
||||
* The MCP SDK accepts `image` and `audio` blocks with base64 data + mimeType.
|
||||
*/
|
||||
export type EmbeddedContent =
|
||||
| { type: 'image'; data: string; mimeType: string }
|
||||
| { type: 'audio'; data: string; mimeType: string };
|
||||
|
||||
/**
|
||||
* Build an embedded content block from a local file, respecting a byte cap.
|
||||
* Returns null when the file is missing, too large, or not embeddable.
|
||||
*/
|
||||
export function buildEmbeddedContent(
|
||||
filePath: string,
|
||||
mimeType: string | undefined,
|
||||
maxBytes: number
|
||||
): EmbeddedContent | null {
|
||||
try {
|
||||
const st = fs.statSync(filePath);
|
||||
if (!st.isFile() || st.size > maxBytes) return null;
|
||||
|
||||
const mime = mimeType || extToMime(path.extname(filePath));
|
||||
const buf = fs.readFileSync(filePath);
|
||||
const data = buf.toString('base64');
|
||||
|
||||
if (mime.startsWith('image/')) return { type: 'image', data, mimeType: mime };
|
||||
if (mime.startsWith('audio/')) return { type: 'audio', data, mimeType: mime };
|
||||
return null; // video/other are not embeddable as MCP content
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fetch, ProxyAgent } from 'undici';
|
||||
import { NexusError } from '../shared/errors.js';
|
||||
import { createLogger } from '../shared/logger.js';
|
||||
|
||||
const log = createLogger('storage');
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/mp3': 'mp3',
|
||||
'audio/wav': 'wav',
|
||||
'audio/x-wav': 'wav',
|
||||
'audio/ogg': 'ogg',
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/webp': 'webp',
|
||||
'video/mp4': 'mp4',
|
||||
'application/octet-stream': 'bin',
|
||||
};
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
mp4: 'video/mp4',
|
||||
};
|
||||
|
||||
export function mimeToExt(mime?: string): string {
|
||||
if (!mime) return 'bin';
|
||||
return EXT_BY_MIME[mime.toLowerCase()] || 'bin';
|
||||
}
|
||||
|
||||
export function extToMime(ext: string): string {
|
||||
return MIME_BY_EXT[ext.toLowerCase().replace(/^\./, '')] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function proxyDispatcher(): ProxyAgent | undefined {
|
||||
const url =
|
||||
process.env.NEXUS_HTTP_PROXY || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
|
||||
return url ? new ProxyAgent(url) : undefined;
|
||||
}
|
||||
|
||||
export interface SaveResult {
|
||||
filePath: string;
|
||||
mimeType: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export class MediaStorage {
|
||||
constructor(private readonly baseDir: string) {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
}
|
||||
|
||||
private uniqueName(prefix: string, ext: string): string {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
return `${prefix}-${stamp}-${rand}.${ext}`;
|
||||
}
|
||||
|
||||
/** Download a remote URL to a local file. */
|
||||
async downloadUrl(url: string, prefix: string): Promise<SaveResult> {
|
||||
const dispatcher = proxyDispatcher();
|
||||
const res = await fetch(url, dispatcher ? { dispatcher } : {});
|
||||
if (!res.ok) {
|
||||
throw new NexusError('IO', `Failed to download result (${res.status}) from ${url}`);
|
||||
}
|
||||
const mimeType = res.headers.get('content-type')?.split(';')[0]?.trim() || 'application/octet-stream';
|
||||
const urlExt = path.extname(new URL(url).pathname).replace(/^\./, '');
|
||||
const ext = urlExt || mimeToExt(mimeType);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
const filePath = path.join(this.baseDir, this.uniqueName(prefix, ext));
|
||||
fs.writeFileSync(filePath, buf);
|
||||
log.debug('saved file', { filePath, bytes: buf.length });
|
||||
return { filePath, mimeType: mimeType === 'application/octet-stream' ? extToMime(ext) : mimeType, bytes: buf.length };
|
||||
}
|
||||
|
||||
/** Save a base64 (optionally data-URL) payload to disk. */
|
||||
saveBase64(data: string, prefix: string, mimeHint?: string): SaveResult {
|
||||
let mimeType = mimeHint || 'application/octet-stream';
|
||||
let b64 = data;
|
||||
const dataUrlMatch = /^data:([^;]+);base64,(.*)$/s.exec(data);
|
||||
if (dataUrlMatch) {
|
||||
mimeType = dataUrlMatch[1];
|
||||
b64 = dataUrlMatch[2];
|
||||
}
|
||||
const buf = Buffer.from(b64, 'base64');
|
||||
const ext = mimeToExt(mimeType);
|
||||
const filePath = path.join(this.baseDir, this.uniqueName(prefix, ext));
|
||||
fs.writeFileSync(filePath, buf);
|
||||
return { filePath, mimeType, bytes: buf.length };
|
||||
}
|
||||
|
||||
/** Remove files older than ttlDays. Best-effort. */
|
||||
cleanup(ttlDays: number): number {
|
||||
let removed = 0;
|
||||
const cutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000;
|
||||
try {
|
||||
for (const name of fs.readdirSync(this.baseDir)) {
|
||||
const full = path.join(this.baseDir, name);
|
||||
try {
|
||||
const st = fs.statSync(full);
|
||||
if (st.isFile() && st.mtimeMs < cutoff && !name.endsWith('.sqlite') && !name.includes('nexus.sqlite')) {
|
||||
fs.unlinkSync(full);
|
||||
removed += 1;
|
||||
}
|
||||
} catch {
|
||||
/* ignore per-file errors */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { fetch, ProxyAgent } from 'undici';
|
||||
import type { SttConfig } from '../../config.js';
|
||||
import { NexusError } from '../../shared/errors.js';
|
||||
import { createLogger } from '../../shared/logger.js';
|
||||
import type { SttInput, SttProvider, SttResult } from '../types.js';
|
||||
|
||||
const log = createLogger('local-stt');
|
||||
|
||||
interface SidecarResponse {
|
||||
ok: boolean;
|
||||
text?: string;
|
||||
language?: string;
|
||||
duration?: number;
|
||||
segments?: Array<{ start: number; end: number; text: string }>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local speech-to-text via a faster-whisper Python sidecar launched with `uv`.
|
||||
* The sidecar reads a single JSON request on argv and prints a JSON response.
|
||||
* Architecture allows swapping in cloud STT adapters later without MCP changes.
|
||||
*/
|
||||
export class LocalWhisperProvider implements SttProvider {
|
||||
readonly name = 'local';
|
||||
|
||||
constructor(private readonly cfg: SttConfig) {}
|
||||
|
||||
private resolveSidecar(): string {
|
||||
if (this.cfg.sidecarPath) return this.cfg.sidecarPath;
|
||||
// packages/core/dist/providers/local-stt/ -> repo/sidecar/stt_server.py
|
||||
const guess = path.resolve(__dirname, '../../../../../sidecar/stt_server.py');
|
||||
return guess;
|
||||
}
|
||||
|
||||
private async materializeAudio(input: SttInput): Promise<{ file: string; cleanup: boolean }> {
|
||||
if (input.audioPath) {
|
||||
if (!fs.existsSync(input.audioPath)) {
|
||||
throw new NexusError('IO', `Audio file not found: ${input.audioPath}`);
|
||||
}
|
||||
return { file: input.audioPath, cleanup: false };
|
||||
}
|
||||
if (input.audioBase64) {
|
||||
const m = /^data:([^;]+);base64,(.*)$/s.exec(input.audioBase64);
|
||||
const b64 = m ? m[2] : input.audioBase64;
|
||||
const tmp = path.join(os.tmpdir(), `nexus-stt-${Date.now()}-${Math.random().toString(36).slice(2)}.audio`);
|
||||
fs.writeFileSync(tmp, Buffer.from(b64, 'base64'));
|
||||
return { file: tmp, cleanup: true };
|
||||
}
|
||||
if (input.audioUrl) {
|
||||
const proxy = process.env.NEXUS_HTTP_PROXY || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
|
||||
const res = await fetch(input.audioUrl, proxy ? { dispatcher: new ProxyAgent(proxy) } : {});
|
||||
if (!res.ok) throw new NexusError('IO', `Failed to download audio (${res.status})`);
|
||||
const tmp = path.join(os.tmpdir(), `nexus-stt-${Date.now()}-${Math.random().toString(36).slice(2)}.audio`);
|
||||
fs.writeFileSync(tmp, Buffer.from(await res.arrayBuffer()));
|
||||
return { file: tmp, cleanup: true };
|
||||
}
|
||||
throw new NexusError('BAD_REQUEST', 'Provide one of: audioPath, audioUrl, audioBase64');
|
||||
}
|
||||
|
||||
async transcribe(input: SttInput): Promise<SttResult> {
|
||||
const { file, cleanup } = await this.materializeAudio(input);
|
||||
const sidecar = this.resolveSidecar();
|
||||
|
||||
const request = {
|
||||
audio: file,
|
||||
model: input.model || this.cfg.model,
|
||||
device: this.cfg.device,
|
||||
compute_type: this.cfg.computeType,
|
||||
language: input.language,
|
||||
};
|
||||
|
||||
// With uv: `uv run --with faster-whisper python sidecar/stt_server.py <json>`
|
||||
const usingUv = /(^|\/)uv$/.test(this.cfg.pythonCmd) || this.cfg.pythonCmd === 'uv';
|
||||
const sidecarDir = path.dirname(sidecar);
|
||||
const args = usingUv
|
||||
? ['run', sidecar, JSON.stringify(request)]
|
||||
: [sidecar, JSON.stringify(request)];
|
||||
|
||||
log.debug('spawning stt sidecar', { cmd: this.cfg.pythonCmd, model: request.model });
|
||||
|
||||
try {
|
||||
const response = await this.runSidecar(this.cfg.pythonCmd, args, sidecarDir);
|
||||
if (!response.ok) {
|
||||
throw new NexusError('PROVIDER', response.error || 'STT sidecar failed');
|
||||
}
|
||||
return {
|
||||
provider: this.name,
|
||||
text: response.text || '',
|
||||
language: response.language,
|
||||
durationSec: response.duration,
|
||||
segments: response.segments,
|
||||
};
|
||||
} finally {
|
||||
if (cleanup) {
|
||||
try {
|
||||
fs.unlinkSync(file);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private runSidecar(cmd: string, args: string[], cwd: string): Promise<SidecarResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Forward HF mirror / offline settings so the sidecar can locate models
|
||||
// in restricted networks.
|
||||
const env = {
|
||||
...process.env,
|
||||
...(process.env.HF_ENDPOINT ? { HF_ENDPOINT: process.env.HF_ENDPOINT } : {}),
|
||||
};
|
||||
const child = spawn(cmd, args, { cwd, env });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (d) => (stdout += d.toString()));
|
||||
child.stderr.on('data', (d) => (stderr += d.toString()));
|
||||
child.on('error', (err) =>
|
||||
reject(new NexusError('CONFIG', `Failed to launch STT sidecar (${cmd}): ${err.message}`))
|
||||
);
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0 && !stdout.trim()) {
|
||||
reject(new NexusError('PROVIDER', `STT sidecar exited ${code}: ${stderr.slice(-500)}`));
|
||||
return;
|
||||
}
|
||||
// The sidecar prints exactly one JSON line on stdout.
|
||||
const line = stdout.trim().split('\n').filter(Boolean).pop() || '';
|
||||
try {
|
||||
resolve(JSON.parse(line) as SidecarResponse);
|
||||
} catch {
|
||||
reject(new NexusError('PROVIDER', `Invalid STT sidecar output: ${stdout.slice(-500)} | stderr: ${stderr.slice(-300)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { createLogger } from '../shared/logger.js';
|
||||
import type { Capability, MediaProvider, ModelInfo } from './types.js';
|
||||
|
||||
const log = createLogger('model-catalog');
|
||||
|
||||
/** Map a NordRouter model `type` to our Capability. */
|
||||
export function typeToCapability(type?: string): Capability | undefined {
|
||||
switch ((type || '').toLowerCase()) {
|
||||
case 'audio':
|
||||
return 'tts';
|
||||
case 'image':
|
||||
return 'image';
|
||||
case 'video':
|
||||
return 'video';
|
||||
case 'music':
|
||||
return 'music';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
/** Exact match found. */
|
||||
exact: boolean;
|
||||
/** The chosen model id (may differ from the requested one when fuzzy-matched). */
|
||||
model: string;
|
||||
/** Human note explaining substitution, if any. */
|
||||
note?: string;
|
||||
/** Alternatives in the same category (for error messages / agent guidance). */
|
||||
suggestions?: Array<{ id: string; label?: string; estUsd?: number }>;
|
||||
}
|
||||
|
||||
export class ModelResolutionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly capability: Capability,
|
||||
public readonly suggestions: Array<{ id: string; label?: string; estUsd?: number }>
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ModelResolutionError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize an id/label for fuzzy comparison. */
|
||||
function norm(s: string): string {
|
||||
return s.toLowerCase().replace(/^(image|video|audio|music)\//, '').replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
/** Length of the longest common subsequence. */
|
||||
function lcs(a: string, b: string): number {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
if (!m || !n) return 0;
|
||||
let prev = new Array(n + 1).fill(0);
|
||||
let cur = new Array(n + 1).fill(0);
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
cur[j] = a[i - 1] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], cur[j - 1]);
|
||||
}
|
||||
[prev, cur] = [cur, prev];
|
||||
}
|
||||
return prev[n];
|
||||
}
|
||||
|
||||
/** Cheap similarity in [0,1] combining token overlap and character-level LCS. */
|
||||
function similarity(a: string, b: string): number {
|
||||
const na = norm(a);
|
||||
const nb = norm(b);
|
||||
if (!na || !nb) return 0;
|
||||
if (na === nb) return 1;
|
||||
if (na.includes(nb) || nb.includes(na)) return 0.85;
|
||||
|
||||
// Token overlap on the original strings.
|
||||
const ta = new Set(a.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
||||
const tb = new Set(b.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
||||
let inter = 0;
|
||||
for (const t of ta) if (tb.has(t)) inter += 1;
|
||||
const tokenScore = ta.size && tb.size ? inter / Math.max(ta.size, tb.size) : 0;
|
||||
|
||||
// Character-level LCS on normalized strings (handles minor typos / punctuation).
|
||||
const charScore = lcs(na, nb) / Math.max(na.length, nb.length);
|
||||
|
||||
return Math.max(tokenScore, charScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Caches a provider's model catalog and resolves (possibly misspelled) model ids
|
||||
* to the nearest valid model within the same capability, preferring the cheapest
|
||||
* when several are similar. Periodically refreshed in the background.
|
||||
*/
|
||||
export class ModelCatalog {
|
||||
private models: ModelInfo[] = [];
|
||||
private lastSync = 0;
|
||||
private syncing?: Promise<void>;
|
||||
private timer?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly provider: MediaProvider,
|
||||
private readonly opts: { ttlMs?: number; minSimilarity?: number } = {}
|
||||
) {}
|
||||
|
||||
get ttlMs(): number {
|
||||
return this.opts.ttlMs ?? 10 * 60 * 1000;
|
||||
}
|
||||
get minSimilarity(): number {
|
||||
return this.opts.minSimilarity ?? 0.4;
|
||||
}
|
||||
|
||||
/** Start periodic background sync. Safe to call once. */
|
||||
startAutoSync(intervalMs = 30 * 60 * 1000): void {
|
||||
if (this.timer) return;
|
||||
// Fire once soon, then on interval. Unref so it never blocks process exit.
|
||||
this.timer = setInterval(() => void this.sync().catch(() => {}), intervalMs);
|
||||
if (typeof this.timer.unref === 'function') this.timer.unref();
|
||||
void this.sync().catch(() => {});
|
||||
}
|
||||
|
||||
stopAutoSync(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async sync(force = false): Promise<void> {
|
||||
if (!this.provider.listModels) return;
|
||||
if (!force && Date.now() - this.lastSync < this.ttlMs && this.models.length) return;
|
||||
if (this.syncing) return this.syncing;
|
||||
this.syncing = (async () => {
|
||||
try {
|
||||
const models = await this.provider.listModels!();
|
||||
this.models = models;
|
||||
this.lastSync = Date.now();
|
||||
log.debug(`synced ${models.length} models for provider "${this.provider.name}"`);
|
||||
} catch (err) {
|
||||
log.warn('model sync failed', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
this.syncing = undefined;
|
||||
}
|
||||
})();
|
||||
return this.syncing;
|
||||
}
|
||||
|
||||
/** All known models, syncing lazily if the cache is empty/stale. */
|
||||
async all(): Promise<ModelInfo[]> {
|
||||
await this.sync();
|
||||
return this.models;
|
||||
}
|
||||
|
||||
/** Models for a given capability. */
|
||||
async byCapability(capability: Capability): Promise<ModelInfo[]> {
|
||||
const all = await this.all();
|
||||
return all.filter((m) => typeToCapability(m.type) === capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a requested model id for a capability.
|
||||
* - Exact id match → return as-is.
|
||||
* - Otherwise pick the most similar model above `minSimilarity`; when several
|
||||
* are equally close, prefer the cheapest.
|
||||
* - If nothing is close enough, throw ModelResolutionError with suggestions.
|
||||
*/
|
||||
async resolve(requested: string, capability: Capability): Promise<ResolveResult> {
|
||||
const pool = await this.byCapability(capability);
|
||||
|
||||
// If we couldn't load a catalog at all, don't block the call — trust the id.
|
||||
if (!pool.length) {
|
||||
return { exact: true, model: requested, note: 'catalog unavailable; passing id through' };
|
||||
}
|
||||
|
||||
const exact = pool.find((m) => m.id === requested);
|
||||
if (exact) return { exact: true, model: exact.id };
|
||||
|
||||
const scored = pool
|
||||
.map((m) => ({ m, score: Math.max(similarity(requested, m.id), similarity(requested, m.label || '')) }))
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return (a.m.estUsd ?? Infinity) - (b.m.estUsd ?? Infinity); // tie-break: cheapest
|
||||
});
|
||||
|
||||
const best = scored[0];
|
||||
const suggestions = pool
|
||||
.slice()
|
||||
.sort((a, b) => (a.estUsd ?? Infinity) - (b.estUsd ?? Infinity))
|
||||
.slice(0, 10)
|
||||
.map((m) => ({ id: m.id, label: m.label, estUsd: m.estUsd }));
|
||||
|
||||
if (best && best.score >= this.minSimilarity) {
|
||||
return {
|
||||
exact: false,
|
||||
model: best.m.id,
|
||||
note: `Model "${requested}" not found; using closest match "${best.m.id}"${
|
||||
best.m.estUsd != null ? ` (est $${best.m.estUsd})` : ''
|
||||
}.`,
|
||||
suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
throw new ModelResolutionError(
|
||||
`No model matching "${requested}" for capability "${capability}". Ask the user to pick one of the available models.`,
|
||||
capability,
|
||||
suggestions
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { HttpClient, RateLimiter } from '../../shared/http.js';
|
||||
import { NexusError } from '../../shared/errors.js';
|
||||
|
||||
/**
|
||||
* Low-level NordRouter HTTP client. Shared by media, search, upload and models.
|
||||
* Auth: `Authorization: Bearer sk-...`. Base: https://nordrouter.com
|
||||
*/
|
||||
export class NordRouterClient {
|
||||
readonly http: HttpClient;
|
||||
readonly baseUrl: string;
|
||||
|
||||
constructor(apiKey: string | undefined, baseUrl: string) {
|
||||
if (!apiKey) {
|
||||
throw new NexusError('CONFIG', 'NORDROUTER_API_KEY is not set');
|
||||
}
|
||||
this.baseUrl = baseUrl;
|
||||
this.http = new HttpClient({
|
||||
baseUrl,
|
||||
defaultHeaders: { Authorization: `Bearer ${apiKey}` },
|
||||
timeoutMs: 60_000,
|
||||
maxRetries: 2,
|
||||
// NordRouter rate-limits per key; keep it modest and let 429 retries handle bursts.
|
||||
rateLimiter: new RateLimiter(30, 10_000),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { NordRouterConfig } from '../../config.js';
|
||||
import type { MediaStorage } from '../../media/storage.js';
|
||||
import { NordRouterClient } from './client.js';
|
||||
import { NordRouterMediaProvider } from './media.js';
|
||||
import { NordRouterSearchProvider } from './search.js';
|
||||
|
||||
export { NordRouterClient } from './client.js';
|
||||
export { NordRouterMediaProvider } from './media.js';
|
||||
export { NordRouterSearchProvider } from './search.js';
|
||||
|
||||
export interface NordRouterFactoryOptions {
|
||||
config: NordRouterConfig;
|
||||
storage: MediaStorage;
|
||||
save: boolean;
|
||||
}
|
||||
|
||||
export function createNordRouterProviders(opts: NordRouterFactoryOptions): {
|
||||
media: NordRouterMediaProvider;
|
||||
search: NordRouterSearchProvider;
|
||||
} {
|
||||
const client = new NordRouterClient(opts.config.apiKey, opts.config.baseUrl);
|
||||
return {
|
||||
media: new NordRouterMediaProvider(client, { save: opts.save, storage: opts.storage }),
|
||||
search: new NordRouterSearchProvider(client),
|
||||
};
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import { NexusError } from '../../shared/errors.js';
|
||||
import { createLogger } from '../../shared/logger.js';
|
||||
import { pollUntilDone } from '../../shared/polling.js';
|
||||
import { MediaStorage } from '../../media/storage.js';
|
||||
import type {
|
||||
Capability,
|
||||
EstimateInput,
|
||||
MediaProvider,
|
||||
MediaRequest,
|
||||
MediaResult,
|
||||
ModelInfo,
|
||||
UploadInput,
|
||||
UploadResult,
|
||||
} from '../types.js';
|
||||
import { NordRouterClient } from './client.js';
|
||||
|
||||
const log = createLogger('nordrouter:media');
|
||||
|
||||
interface JobCreateResponse {
|
||||
id: string;
|
||||
model: string;
|
||||
status: string;
|
||||
poll?: string;
|
||||
webhook?: string;
|
||||
}
|
||||
|
||||
interface JobStatusResponse {
|
||||
id: string;
|
||||
status: 'processing' | 'done' | 'failed';
|
||||
result_url: string | null;
|
||||
result_url_2?: string | null;
|
||||
results?: Array<{ index: number; name: string; url: string }>;
|
||||
cost_usd?: number | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface ModelsResponse {
|
||||
models: Array<{ id: string; type?: string; label?: string; est_usd?: number; fields?: unknown }>;
|
||||
retention_days?: number;
|
||||
}
|
||||
|
||||
/** Timeout per capability — images are quick, video/music take longer. */
|
||||
const TIMEOUT_MS: Record<Capability, number> = {
|
||||
image: 3 * 60 * 1000,
|
||||
tts: 3 * 60 * 1000,
|
||||
music: 8 * 60 * 1000,
|
||||
video: 15 * 60 * 1000,
|
||||
stt: 3 * 60 * 1000,
|
||||
search: 60 * 1000,
|
||||
};
|
||||
|
||||
const PREFIX: Record<Capability, string> = {
|
||||
image: 'image',
|
||||
tts: 'audio',
|
||||
music: 'music',
|
||||
video: 'video',
|
||||
stt: 'stt',
|
||||
search: 'search',
|
||||
};
|
||||
|
||||
export interface NordRouterMediaOptions {
|
||||
save: boolean;
|
||||
storage?: MediaStorage;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
export class NordRouterMediaProvider implements MediaProvider {
|
||||
readonly name = 'nordrouter';
|
||||
readonly capabilities: Capability[] = ['tts', 'image', 'video', 'music'];
|
||||
|
||||
private modelsCache?: { at: number; models: ModelInfo[] };
|
||||
private readonly modelsTtlMs = 10 * 60 * 1000;
|
||||
|
||||
constructor(
|
||||
private readonly client: NordRouterClient,
|
||||
private readonly defaults: NordRouterMediaOptions
|
||||
) {}
|
||||
|
||||
async generate(capability: Capability, req: MediaRequest): Promise<MediaResult> {
|
||||
const created = await this.client.http.requestJson<JobCreateResponse>({
|
||||
method: 'POST',
|
||||
path: '/media/generate',
|
||||
body: { model: req.model, input: req.input },
|
||||
});
|
||||
|
||||
if (!created.id) {
|
||||
throw new NexusError('PROVIDER', 'NordRouter did not return a job id', created);
|
||||
}
|
||||
log.debug('job created', { id: created.id, model: req.model });
|
||||
|
||||
const final = await pollUntilDone<JobStatusResponse>({
|
||||
intervalMs: this.defaults.pollIntervalMs ?? 3000,
|
||||
timeoutMs: TIMEOUT_MS[capability],
|
||||
poll: async () => {
|
||||
const s = await this.client.http.requestJson<JobStatusResponse>({
|
||||
method: 'GET',
|
||||
path: `/media/job/${encodeURIComponent(created.id)}`,
|
||||
});
|
||||
if (s.status === 'failed') return { done: false, failed: true, error: s.error || 'job failed' };
|
||||
if (s.status === 'done') return { done: true, value: s };
|
||||
return { done: false };
|
||||
},
|
||||
});
|
||||
|
||||
const result: MediaResult = {
|
||||
provider: this.name,
|
||||
capability,
|
||||
model: req.model,
|
||||
status: 'done',
|
||||
resultUrl: final.result_url ?? undefined,
|
||||
resultUrl2: final.result_url_2 ?? undefined,
|
||||
costUsd: final.cost_usd ?? undefined,
|
||||
raw: final,
|
||||
};
|
||||
|
||||
if (this.defaults.save && this.defaults.storage && final.result_url) {
|
||||
const saved = await this.defaults.storage.downloadUrl(final.result_url, PREFIX[capability]);
|
||||
result.filePath = saved.filePath;
|
||||
result.mimeType = saved.mimeType;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async estimate(req: EstimateInput): Promise<{ usd: number }> {
|
||||
return this.client.http.requestJson<{ usd: number }>({
|
||||
method: 'POST',
|
||||
path: '/media/estimate',
|
||||
body: { model: req.model, input: req.input },
|
||||
});
|
||||
}
|
||||
|
||||
async upload(input: UploadInput): Promise<UploadResult> {
|
||||
if (!fs.existsSync(input.path)) {
|
||||
throw new NexusError('IO', `Upload file not found: ${input.path}`);
|
||||
}
|
||||
const buf = fs.readFileSync(input.path);
|
||||
const ext = input.path.split('.').pop()?.toLowerCase() || '';
|
||||
const mimeByExt: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
mp4: 'video/mp4',
|
||||
mov: 'video/quicktime',
|
||||
wav: 'audio/wav',
|
||||
mp3: 'audio/mpeg',
|
||||
ogg: 'audio/ogg',
|
||||
m4a: 'audio/mp4',
|
||||
};
|
||||
const mime = input.mimeType || mimeByExt[ext] || 'application/octet-stream';
|
||||
const name = input.name || input.path.split('/').pop() || 'file';
|
||||
const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
||||
const res = await this.client.http.requestJson<{ url?: string }>({
|
||||
method: 'POST',
|
||||
path: '/media/upload',
|
||||
body: { data: dataUrl, name },
|
||||
});
|
||||
return { ref: res, url: res.url };
|
||||
}
|
||||
|
||||
/** Validate the API key with a zero-cost authenticated call. */
|
||||
async checkKey(): Promise<{ ok: boolean; modelsCount?: number; error?: string }> {
|
||||
try {
|
||||
const models = await this.listModels();
|
||||
return { ok: true, modelsCount: models.length };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async listModels(): Promise<ModelInfo[]> {
|
||||
const now = Date.now();
|
||||
if (this.modelsCache && now - this.modelsCache.at < this.modelsTtlMs) {
|
||||
return this.modelsCache.models;
|
||||
}
|
||||
const res = await this.client.http.requestJson<ModelsResponse>({
|
||||
method: 'GET',
|
||||
path: '/media/models',
|
||||
});
|
||||
const models: ModelInfo[] = (res.models || []).map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
label: m.label,
|
||||
estUsd: m.est_usd,
|
||||
fields: m.fields,
|
||||
}));
|
||||
this.modelsCache = { at: now, models };
|
||||
return models;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { createLogger } from '../../shared/logger.js';
|
||||
import type { SearchInput, SearchProvider, SearchResult } from '../types.js';
|
||||
import { NordRouterClient } from './client.js';
|
||||
|
||||
const log = createLogger('nordrouter:search');
|
||||
|
||||
interface ChatResponse {
|
||||
choices: Array<{
|
||||
message: {
|
||||
content: string;
|
||||
annotations?: Array<{
|
||||
type: string;
|
||||
url_citation?: { url: string; title?: string };
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
usage?: { total_cost?: number };
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL = 'perplexity/sonar-pro';
|
||||
|
||||
/**
|
||||
* Web search via NordRouter's OpenAI-compatible chat endpoint using
|
||||
* Perplexity sonar / search-augmented models. Citations arrive in
|
||||
* `message.annotations[].url_citation`.
|
||||
*/
|
||||
export class NordRouterSearchProvider implements SearchProvider {
|
||||
readonly name = 'nordrouter';
|
||||
|
||||
constructor(private readonly client: NordRouterClient) {}
|
||||
|
||||
async search(input: SearchInput): Promise<SearchResult> {
|
||||
const model = input.model || DEFAULT_MODEL;
|
||||
let query = input.query;
|
||||
if (input.recency) {
|
||||
query += `\n\n(Focus on information from the last ${input.recency}.)`;
|
||||
}
|
||||
|
||||
const res = await this.client.http.requestJson<ChatResponse>({
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
body: { model, messages: [{ role: 'user', content: query }] },
|
||||
timeoutMs: 90_000,
|
||||
});
|
||||
|
||||
const choice = res.choices?.[0]?.message;
|
||||
const answer = choice?.content ?? '';
|
||||
const seen = new Set<string>();
|
||||
const citations: SearchResult['citations'] = [];
|
||||
let idx = 1;
|
||||
for (const a of choice?.annotations ?? []) {
|
||||
const c = a.url_citation;
|
||||
if (c?.url && !seen.has(c.url)) {
|
||||
seen.add(c.url);
|
||||
citations.push({ index: idx++, url: c.url, title: c.title });
|
||||
}
|
||||
}
|
||||
if (input.maxResults && citations.length > input.maxResults) {
|
||||
citations.length = input.maxResults;
|
||||
}
|
||||
|
||||
log.debug('search done', { model, citations: citations.length });
|
||||
return {
|
||||
provider: this.name,
|
||||
model,
|
||||
answer,
|
||||
citations,
|
||||
costUsd: res.usage?.total_cost,
|
||||
raw: res,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { NexusError } from '../shared/errors.js';
|
||||
import type { Capability, MediaProvider, SearchProvider, SttProvider } from './types.js';
|
||||
|
||||
/**
|
||||
* Registry maps a provider name + capability to the concrete adapter.
|
||||
* Adding RouterAI / GPTunnel / OpenRouter / cloud STT means registering a new
|
||||
* adapter here — no changes needed in the MCP tool layer.
|
||||
*/
|
||||
export class ProviderRegistry {
|
||||
private media = new Map<string, MediaProvider>();
|
||||
private search = new Map<string, SearchProvider>();
|
||||
private stt = new Map<string, SttProvider>();
|
||||
|
||||
registerMedia(provider: MediaProvider): void {
|
||||
this.media.set(provider.name, provider);
|
||||
}
|
||||
registerSearch(provider: SearchProvider): void {
|
||||
this.search.set(provider.name, provider);
|
||||
}
|
||||
registerStt(provider: SttProvider): void {
|
||||
this.stt.set(provider.name, provider);
|
||||
}
|
||||
|
||||
getMedia(name: string, capability: Capability): MediaProvider {
|
||||
const p = this.media.get(name);
|
||||
if (!p) {
|
||||
throw new NexusError(
|
||||
'UNSUPPORTED',
|
||||
`Unknown media provider "${name}". Available: ${[...this.media.keys()].join(', ') || 'none'}`
|
||||
);
|
||||
}
|
||||
if (!p.capabilities.includes(capability)) {
|
||||
throw new NexusError(
|
||||
'UNSUPPORTED',
|
||||
`Provider "${name}" does not support capability "${capability}"`
|
||||
);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
getSearch(name: string): SearchProvider {
|
||||
const p = this.search.get(name);
|
||||
if (!p) {
|
||||
throw new NexusError(
|
||||
'UNSUPPORTED',
|
||||
`Unknown search provider "${name}". Available: ${[...this.search.keys()].join(', ') || 'none'}`
|
||||
);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
getStt(name: string): SttProvider {
|
||||
const p = this.stt.get(name);
|
||||
if (!p) {
|
||||
throw new NexusError(
|
||||
'UNSUPPORTED',
|
||||
`Unknown STT provider "${name}". Available: ${[...this.stt.keys()].join(', ') || 'none'}`
|
||||
);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
listMediaProviders(): string[] {
|
||||
return [...this.media.keys()];
|
||||
}
|
||||
listSearchProviders(): string[] {
|
||||
return [...this.search.keys()];
|
||||
}
|
||||
listSttProviders(): string[] {
|
||||
return [...this.stt.keys()];
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Provider abstraction. Adapters (NordRouter, RouterAI, GPTunnel, OpenRouter,
|
||||
* local STT) implement one or more of these capabilities. The MCP layer stays
|
||||
* provider-agnostic and selects an adapter by an explicit `provider` argument.
|
||||
*/
|
||||
|
||||
export type Capability = 'tts' | 'stt' | 'image' | 'video' | 'music' | 'search';
|
||||
|
||||
export interface MediaRequest {
|
||||
model: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MediaResult {
|
||||
provider: string;
|
||||
capability: Capability;
|
||||
model: string;
|
||||
status: 'done';
|
||||
/** Local saved file path (when storage enabled). */
|
||||
filePath?: string;
|
||||
mimeType?: string;
|
||||
/** Remote URL from provider (may expire). */
|
||||
resultUrl?: string;
|
||||
/** Second track for music providers (Suno). */
|
||||
resultUrl2?: string;
|
||||
costUsd?: number;
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface SttInput {
|
||||
/** One of: local path, remote URL, or base64 data URL. */
|
||||
audioPath?: string;
|
||||
audioUrl?: string;
|
||||
audioBase64?: string;
|
||||
language?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface SttResult {
|
||||
provider: string;
|
||||
text: string;
|
||||
language?: string;
|
||||
durationSec?: number;
|
||||
segments?: Array<{ start: number; end: number; text: string }>;
|
||||
}
|
||||
|
||||
export interface SearchInput {
|
||||
query: string;
|
||||
model?: string;
|
||||
recency?: 'day' | 'week' | 'month' | 'year';
|
||||
maxResults?: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
provider: string;
|
||||
model: string;
|
||||
answer: string;
|
||||
citations: Array<{ index?: number; url: string; title?: string }>;
|
||||
costUsd?: number;
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
type?: string;
|
||||
label?: string;
|
||||
estUsd?: number;
|
||||
fields?: unknown;
|
||||
}
|
||||
|
||||
export interface EstimateInput {
|
||||
model: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UploadInput {
|
||||
/** Local path to read and upload. */
|
||||
path: string;
|
||||
name?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
/** Whatever the provider returns to reference the uploaded asset later. */
|
||||
ref: unknown;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface MediaProvider {
|
||||
readonly name: string;
|
||||
readonly capabilities: Capability[];
|
||||
generate(capability: Capability, req: MediaRequest): Promise<MediaResult>;
|
||||
estimate?(req: EstimateInput): Promise<{ usd: number }>;
|
||||
upload?(input: UploadInput): Promise<UploadResult>;
|
||||
listModels?(): Promise<ModelInfo[]>;
|
||||
}
|
||||
|
||||
export interface SearchProvider {
|
||||
readonly name: string;
|
||||
search(input: SearchInput): Promise<SearchResult>;
|
||||
}
|
||||
|
||||
export interface SttProvider {
|
||||
readonly name: string;
|
||||
transcribe(input: SttInput): Promise<SttResult>;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { NexusError } from '../shared/errors.js';
|
||||
|
||||
/**
|
||||
* AES-256-GCM encryption for API keys at rest.
|
||||
*
|
||||
* The master key is provided via env NEXUS_MASTER_KEY and NEVER stored in the DB.
|
||||
* Only encrypted key material lives in SQLite, so backups / inspection of the DB
|
||||
* do not leak provider API keys. Rotating NEXUS_MASTER_KEY invalidates stored
|
||||
* ciphertext (re-enter keys), which is the intended security trade-off.
|
||||
*
|
||||
* Ciphertext format (base64): [ 12-byte IV | 16-byte auth tag | ciphertext ]
|
||||
*/
|
||||
|
||||
const ALGO = 'aes-256-gcm';
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
/**
|
||||
* Derive a 32-byte key from the env master secret. Accepts either a 64-char
|
||||
* hex string (used directly) or any passphrase (hashed with SHA-256).
|
||||
*/
|
||||
export function deriveMasterKey(secret: string | undefined): Buffer | undefined {
|
||||
if (!secret) return undefined;
|
||||
const trimmed = secret.trim();
|
||||
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
||||
return Buffer.from(trimmed, 'hex');
|
||||
}
|
||||
return crypto.createHash('sha256').update(trimmed, 'utf8').digest();
|
||||
}
|
||||
|
||||
export class KeyCipher {
|
||||
private readonly key?: Buffer;
|
||||
|
||||
constructor(masterSecret: string | undefined) {
|
||||
this.key = deriveMasterKey(masterSecret);
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.key !== undefined;
|
||||
}
|
||||
|
||||
private requireKey(): Buffer {
|
||||
if (!this.key) {
|
||||
throw new NexusError(
|
||||
'CONFIG',
|
||||
'NEXUS_MASTER_KEY is not set; cannot encrypt/decrypt stored API keys'
|
||||
);
|
||||
}
|
||||
return this.key;
|
||||
}
|
||||
|
||||
encrypt(plaintext: string): string {
|
||||
const key = this.requireKey();
|
||||
const iv = crypto.randomBytes(IV_LEN);
|
||||
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
||||
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, enc]).toString('base64');
|
||||
}
|
||||
|
||||
decrypt(payload: string): string {
|
||||
const key = this.requireKey();
|
||||
const raw = Buffer.from(payload, 'base64');
|
||||
if (raw.length < IV_LEN + TAG_LEN) {
|
||||
throw new NexusError('CONFIG', 'Malformed encrypted key payload');
|
||||
}
|
||||
const iv = raw.subarray(0, IV_LEN);
|
||||
const tag = raw.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||
const enc = raw.subarray(IV_LEN + TAG_LEN);
|
||||
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
try {
|
||||
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
throw new NexusError(
|
||||
'CONFIG',
|
||||
'Failed to decrypt API key (wrong NEXUS_MASTER_KEY or corrupted data)'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mask a secret for display: keep last 4 chars. */
|
||||
export function maskSecret(secret: string): string {
|
||||
if (secret.length <= 4) return '****';
|
||||
return `${secret.slice(0, 3)}…${secret.slice(-4)}`;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { NexusConfig } from '../config.js';
|
||||
import type { ProvidersStore } from '../db/providers-store.js';
|
||||
import { createLogger } from '../shared/logger.js';
|
||||
|
||||
const log = createLogger('key-resolver');
|
||||
|
||||
export interface ResolvedProvider {
|
||||
name: string;
|
||||
type: string;
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
/** Where the key came from, for diagnostics. */
|
||||
keySource: 'env' | 'db' | 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve effective provider settings.
|
||||
*
|
||||
* Precedence for keys: ENV wins over DB. This keeps simple/CI setups working
|
||||
* (just set NORDROUTER_API_KEY) while allowing admin-managed keys in the DB.
|
||||
*
|
||||
* Built-in providers (nordrouter) are always considered; DB rows can add more
|
||||
* or override base URL / enabled / default.
|
||||
*/
|
||||
export class KeyResolver {
|
||||
constructor(
|
||||
private readonly config: NexusConfig,
|
||||
private readonly store?: ProvidersStore
|
||||
) {}
|
||||
|
||||
private envKeyFor(providerName: string): string | undefined {
|
||||
// Convention: <NAME>_API_KEY, e.g. NORDROUTER_API_KEY
|
||||
const envName = `${providerName.toUpperCase()}_API_KEY`;
|
||||
return process.env[envName];
|
||||
}
|
||||
|
||||
private envBaseUrlFor(providerName: string): string | undefined {
|
||||
const envName = `${providerName.toUpperCase()}_BASE_URL`;
|
||||
return process.env[envName];
|
||||
}
|
||||
|
||||
/** Resolve one provider by name. */
|
||||
resolve(providerName: string, defaultBaseUrl: string): ResolvedProvider {
|
||||
const dbRow = this.store?.get(providerName);
|
||||
const envKey = this.envKeyFor(providerName);
|
||||
// DB key may be unreadable (no master key / rotated key). Degrade gracefully:
|
||||
// fall back to env instead of crashing startup.
|
||||
let dbKey: string | undefined;
|
||||
try {
|
||||
dbKey = this.store?.getApiKey(providerName);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`could not read stored key for "${providerName}" (falling back to env)`,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
dbKey = undefined;
|
||||
}
|
||||
|
||||
const apiKey = envKey ?? dbKey;
|
||||
const keySource: ResolvedProvider['keySource'] = envKey ? 'env' : dbKey ? 'db' : 'none';
|
||||
|
||||
const baseUrl =
|
||||
this.envBaseUrlFor(providerName) || dbRow?.baseUrl || defaultBaseUrl;
|
||||
|
||||
return {
|
||||
name: providerName,
|
||||
type: dbRow?.type ?? providerName,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
enabled: dbRow ? dbRow.enabled : true,
|
||||
isDefault: dbRow?.isDefault ?? false,
|
||||
keySource,
|
||||
};
|
||||
}
|
||||
|
||||
/** All providers to register: built-ins + any DB-managed ones. */
|
||||
resolveAll(builtins: Array<{ name: string; defaultBaseUrl: string }>): ResolvedProvider[] {
|
||||
const seen = new Set<string>();
|
||||
const out: ResolvedProvider[] = [];
|
||||
|
||||
for (const b of builtins) {
|
||||
out.push(this.resolve(b.name, b.defaultBaseUrl));
|
||||
seen.add(b.name);
|
||||
}
|
||||
|
||||
for (const row of this.store?.list() ?? []) {
|
||||
if (seen.has(row.name)) continue;
|
||||
out.push(this.resolve(row.name, row.baseUrl || ''));
|
||||
seen.add(row.name);
|
||||
}
|
||||
|
||||
log.debug(
|
||||
'resolved providers',
|
||||
out.map((p) => ({ name: p.name, key: p.keySource, enabled: p.enabled }))
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The effective default provider name. */
|
||||
resolveDefault(resolved: ResolvedProvider[]): string {
|
||||
const dbDefault = resolved.find((p) => p.isDefault && p.enabled);
|
||||
if (dbDefault) return dbDefault.name;
|
||||
return this.config.defaultProvider;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Unified error type for NexusAI. Every provider adapter should throw NexusError
|
||||
* so the MCP layer can render a consistent { code, message } shape.
|
||||
*/
|
||||
export type NexusErrorCode =
|
||||
| 'CONFIG'
|
||||
| 'AUTH'
|
||||
| 'BAD_REQUEST'
|
||||
| 'NOT_FOUND'
|
||||
| 'RATE_LIMIT'
|
||||
| 'INSUFFICIENT_BALANCE'
|
||||
| 'PROVIDER'
|
||||
| 'TIMEOUT'
|
||||
| 'UNSUPPORTED'
|
||||
| 'IO'
|
||||
| 'UNKNOWN';
|
||||
|
||||
export class NexusError extends Error {
|
||||
readonly code: NexusErrorCode;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(code: NexusErrorCode, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.name = 'NexusError';
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
static from(error: unknown, fallbackCode: NexusErrorCode = 'UNKNOWN'): NexusError {
|
||||
if (error instanceof NexusError) return error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new NexusError(fallbackCode, message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an HTTP status code to a NexusErrorCode. */
|
||||
export function codeFromHttpStatus(status: number): NexusErrorCode {
|
||||
switch (status) {
|
||||
case 400:
|
||||
return 'BAD_REQUEST';
|
||||
case 401:
|
||||
case 403:
|
||||
return 'AUTH';
|
||||
case 402:
|
||||
return 'INSUFFICIENT_BALANCE';
|
||||
case 404:
|
||||
return 'NOT_FOUND';
|
||||
case 429:
|
||||
return 'RATE_LIMIT';
|
||||
default:
|
||||
return status >= 500 ? 'PROVIDER' : 'UNKNOWN';
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { fetch, ProxyAgent, FormData, type RequestInit, type Response } from 'undici';
|
||||
import { NexusError, codeFromHttpStatus } from './errors.js';
|
||||
import { sleep } from './polling.js';
|
||||
|
||||
function getProxyUrl(): string | undefined {
|
||||
return (
|
||||
process.env.NEXUS_HTTP_PROXY ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.https_proxy ||
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.http_proxy
|
||||
);
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
return (
|
||||
msg.includes('timeout') ||
|
||||
msg.includes('econnreset') ||
|
||||
msg.includes('socket') ||
|
||||
msg.includes('fetch failed') ||
|
||||
msg.includes('und_err')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Simple sliding-window rate limiter. */
|
||||
export class RateLimiter {
|
||||
private timestamps: number[] = [];
|
||||
constructor(private readonly maxRequests: number, private readonly windowMs: number) {}
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
while (true) {
|
||||
const now = Date.now();
|
||||
this.timestamps = this.timestamps.filter((ts) => now - ts < this.windowMs);
|
||||
if (this.timestamps.length < this.maxRequests) {
|
||||
this.timestamps.push(now);
|
||||
return;
|
||||
}
|
||||
const oldest = this.timestamps[0];
|
||||
await sleep(Math.max(0, this.windowMs - (now - oldest)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface HttpClientOptions {
|
||||
baseUrl: string;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
maxRetries?: number;
|
||||
rateLimiter?: RateLimiter;
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
/** Skip JSON parsing and return raw Response (for binary downloads). */
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
export class HttpClient {
|
||||
private readonly proxyAgent?: ProxyAgent;
|
||||
|
||||
constructor(private readonly opts: HttpClientOptions) {
|
||||
const proxyUrl = getProxyUrl();
|
||||
if (proxyUrl) this.proxyAgent = new ProxyAgent(proxyUrl);
|
||||
}
|
||||
|
||||
async requestRaw(options: RequestOptions): Promise<Response> {
|
||||
if (this.opts.rateLimiter) await this.opts.rateLimiter.acquire();
|
||||
|
||||
const url = options.path.startsWith('http')
|
||||
? options.path
|
||||
: `${this.opts.baseUrl}${options.path}`;
|
||||
const method = options.method ?? 'GET';
|
||||
const timeoutMs = options.timeoutMs ?? this.opts.timeoutMs ?? 60_000;
|
||||
const maxRetries = this.opts.maxRetries ?? 2;
|
||||
|
||||
const headers: Record<string, string> = { ...this.opts.defaultHeaders, ...options.headers };
|
||||
let bodyStr: string | undefined;
|
||||
let bodyForm: FormData | undefined;
|
||||
if (options.body !== undefined) {
|
||||
if (options.body instanceof FormData) {
|
||||
bodyForm = options.body;
|
||||
} else {
|
||||
bodyStr = typeof options.body === 'string' ? options.body : JSON.stringify(options.body);
|
||||
if (!headers['Content-Type'] && !headers['content-type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const init: RequestInit & { dispatcher?: ProxyAgent } = {
|
||||
method,
|
||||
headers,
|
||||
body: bodyForm ?? bodyStr,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
};
|
||||
if (this.proxyAgent) init.dispatcher = this.proxyAgent;
|
||||
|
||||
const response = await fetch(url, init);
|
||||
|
||||
if ((response.status === 429 || response.status >= 500) && attempt < maxRetries) {
|
||||
await sleep(Math.min(1000 * 2 ** attempt, 8000));
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (isRetryableError(lastError) && attempt < maxRetries) {
|
||||
await sleep(Math.min(1000 * 2 ** attempt, 8000));
|
||||
continue;
|
||||
}
|
||||
if (lastError.name === 'TimeoutError' || lastError.message.includes('aborted')) {
|
||||
throw new NexusError('TIMEOUT', `Request to ${url} timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw NexusError.from(lastError, 'PROVIDER');
|
||||
}
|
||||
}
|
||||
throw NexusError.from(lastError ?? new Error('Request failed'), 'PROVIDER');
|
||||
}
|
||||
|
||||
async requestJson<T = unknown>(options: RequestOptions): Promise<T> {
|
||||
const response = await this.requestRaw(options);
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { message?: string; error?: { message?: string }; msg?: string };
|
||||
message = parsed.message || parsed.error?.message || parsed.msg || message;
|
||||
} catch {
|
||||
if (text) message = `${message}: ${text.slice(0, 300)}`;
|
||||
}
|
||||
throw new NexusError(codeFromHttpStatus(response.status), message, { status: response.status });
|
||||
}
|
||||
|
||||
if (!text) return undefined as T;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new NexusError('PROVIDER', `Invalid JSON response: ${text.slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* MCP servers communicate over stdio, so ALL logs must go to stderr.
|
||||
* Never write logs to stdout — it corrupts the JSON-RPC stream.
|
||||
*/
|
||||
type Level = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const LEVELS: Record<Level, number> = { debug: 10, info: 20, warn: 30, error: 40 };
|
||||
|
||||
function currentThreshold(): number {
|
||||
const env = (process.env.NEXUS_LOG_LEVEL || 'info').toLowerCase() as Level;
|
||||
return LEVELS[env] ?? LEVELS.info;
|
||||
}
|
||||
|
||||
function emit(level: Level, scope: string, message: string, extra?: unknown): void {
|
||||
if (LEVELS[level] < currentThreshold()) return;
|
||||
const ts = new Date().toISOString();
|
||||
let line = `[${ts}] ${level.toUpperCase()} (${scope}) ${message}`;
|
||||
if (extra !== undefined) {
|
||||
try {
|
||||
line += ` ${typeof extra === 'string' ? extra : JSON.stringify(extra)}`;
|
||||
} catch {
|
||||
line += ' [unserializable extra]';
|
||||
}
|
||||
}
|
||||
process.stderr.write(line + '\n');
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug(message: string, extra?: unknown): void;
|
||||
info(message: string, extra?: unknown): void;
|
||||
warn(message: string, extra?: unknown): void;
|
||||
error(message: string, extra?: unknown): void;
|
||||
}
|
||||
|
||||
export function createLogger(scope: string): Logger {
|
||||
return {
|
||||
debug: (m, e) => emit('debug', scope, m, e),
|
||||
info: (m, e) => emit('info', scope, m, e),
|
||||
warn: (m, e) => emit('warn', scope, m, e),
|
||||
error: (m, e) => emit('error', scope, m, e),
|
||||
};
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NexusError } from './errors.js';
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export interface PollOptions<T> {
|
||||
/** Called every interval. Return { done: true, value } to stop. */
|
||||
poll: () => Promise<{ done: boolean; value?: T; failed?: boolean; error?: string }>;
|
||||
intervalMs?: number;
|
||||
timeoutMs?: number;
|
||||
onTick?: (attempt: number, elapsedMs: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic polling loop with wall-clock timeout. Used for async media jobs.
|
||||
*/
|
||||
export async function pollUntilDone<T>(options: PollOptions<T>): Promise<T> {
|
||||
const intervalMs = options.intervalMs ?? 3000;
|
||||
const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
|
||||
const start = Date.now();
|
||||
let attempt = 0;
|
||||
|
||||
while (true) {
|
||||
attempt += 1;
|
||||
const result = await options.poll();
|
||||
options.onTick?.(attempt, Date.now() - start);
|
||||
|
||||
if (result.failed) {
|
||||
throw new NexusError('PROVIDER', result.error || 'Job failed');
|
||||
}
|
||||
if (result.done) {
|
||||
return result.value as T;
|
||||
}
|
||||
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new NexusError('TIMEOUT', `Polling timed out after ${Math.round(timeoutMs / 1000)}s`);
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "@nexusai/mcp-server",
|
||||
"version": "0.1.0",
|
||||
"description": "NexusAI MCP server (stdio): TTS, STT, image/video/music generation, web search",
|
||||
"main": "dist/index.js",
|
||||
"type": "commonjs",
|
||||
"bin": {
|
||||
"nexusai-mcp": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"@nexusai/core": "*",
|
||||
"zod": "^3.25.0",
|
||||
"zod-to-json-schema": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import {
|
||||
Journal,
|
||||
KeyCipher,
|
||||
KeyResolver,
|
||||
MediaStorage,
|
||||
ModelCatalog,
|
||||
ProviderRegistry,
|
||||
ProvidersStore,
|
||||
createNordRouterProviders,
|
||||
loadConfig,
|
||||
LocalWhisperProvider,
|
||||
createLogger,
|
||||
type NexusConfig,
|
||||
} from '@nexusai/core';
|
||||
|
||||
const log = createLogger('context');
|
||||
|
||||
export interface AppContext {
|
||||
config: NexusConfig;
|
||||
registry: ProviderRegistry;
|
||||
storage: MediaStorage;
|
||||
journal: Journal;
|
||||
providersStore: ProvidersStore;
|
||||
/** Model catalogs keyed by provider name (for sync + fuzzy resolution). */
|
||||
catalogs: Map<string, ModelCatalog>;
|
||||
/** Effective default provider name after resolving DB/env. */
|
||||
defaultProvider: string;
|
||||
}
|
||||
|
||||
/** Built-in providers always considered, even without a DB row. */
|
||||
const BUILTINS = [{ name: 'nordrouter', defaultBaseUrl: 'https://nordrouter.com' }];
|
||||
|
||||
/**
|
||||
* Wire up providers, storage, journal and the encrypted providers store.
|
||||
* API keys resolve with ENV > DB precedence (see KeyResolver).
|
||||
*/
|
||||
export function buildContext(): AppContext {
|
||||
const config = loadConfig();
|
||||
const storage = new MediaStorage(config.mediaDir);
|
||||
const journal = new Journal(config.dbPath);
|
||||
const registry = new ProviderRegistry();
|
||||
|
||||
const cipher = new KeyCipher(config.masterKey);
|
||||
const providersStore = new ProvidersStore(config.providersDbPath, cipher);
|
||||
const resolver = new KeyResolver(config, providersStore);
|
||||
|
||||
if (!cipher.enabled) {
|
||||
log.warn('NEXUS_MASTER_KEY not set — DB-stored API keys are unavailable; using env keys only');
|
||||
}
|
||||
|
||||
const resolved = resolver.resolveAll(BUILTINS);
|
||||
const defaultProvider = resolver.resolveDefault(resolved);
|
||||
const catalogs = new Map<string, ModelCatalog>();
|
||||
|
||||
for (const p of resolved) {
|
||||
if (!p.enabled) {
|
||||
log.info(`provider "${p.name}" disabled — skipped`);
|
||||
continue;
|
||||
}
|
||||
if (p.type === 'nordrouter') {
|
||||
if (!p.apiKey) {
|
||||
log.warn(`provider "${p.name}" has no API key (env or DB) — tools will error when used`);
|
||||
}
|
||||
try {
|
||||
const nr = createNordRouterProviders({
|
||||
config: { apiKey: p.apiKey, baseUrl: p.baseUrl },
|
||||
storage,
|
||||
save: true,
|
||||
});
|
||||
// Register under the provider's own name so multiple nordrouter-type
|
||||
// providers with different keys can coexist.
|
||||
Object.defineProperty(nr.media, 'name', { value: p.name });
|
||||
Object.defineProperty(nr.search, 'name', { value: p.name });
|
||||
registry.registerMedia(nr.media);
|
||||
registry.registerSearch(nr.search);
|
||||
// Model catalog with periodic background sync + fuzzy resolution.
|
||||
if (p.apiKey) {
|
||||
const catalog = new ModelCatalog(nr.media);
|
||||
catalog.startAutoSync();
|
||||
catalogs.set(p.name, catalog);
|
||||
}
|
||||
log.info(`registered provider "${p.name}" (media + search, key: ${p.keySource})`);
|
||||
} catch (err) {
|
||||
log.warn(`failed to register "${p.name}"`, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} else {
|
||||
log.warn(`provider type "${p.type}" (${p.name}) not implemented yet — skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
registry.registerStt(new LocalWhisperProvider(config.stt));
|
||||
log.info('registered local STT provider');
|
||||
log.info(`default provider: ${defaultProvider}`);
|
||||
|
||||
return { config, registry, storage, journal, providersStore, catalogs, defaultProvider };
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
ModelResolutionError,
|
||||
NexusError,
|
||||
NordRouterClient,
|
||||
buildEmbeddedContent,
|
||||
createLogger,
|
||||
type Capability,
|
||||
type MediaResult,
|
||||
} from '@nexusai/core';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { AppContext } from './context.js';
|
||||
import {
|
||||
ImageSchema,
|
||||
MediaEstimateSchema,
|
||||
MediaModelsSchema,
|
||||
MediaUploadSchema,
|
||||
MusicSchema,
|
||||
SttSchema,
|
||||
TtsSchema,
|
||||
UsageStatsSchema,
|
||||
VideoSchema,
|
||||
VisionSchema,
|
||||
WebSearchSchema,
|
||||
} from './schemas.js';
|
||||
|
||||
const log = createLogger('handlers');
|
||||
|
||||
type McpContent =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image'; data: string; mimeType: string }
|
||||
| { type: 'audio'; data: string; mimeType: string };
|
||||
|
||||
export interface McpResult {
|
||||
content: McpContent[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
function jsonBlock(obj: unknown): McpContent {
|
||||
return { type: 'text', text: JSON.stringify(obj, null, 2) };
|
||||
}
|
||||
|
||||
function summarize(input: Record<string, unknown>): string {
|
||||
const s = JSON.stringify(input);
|
||||
return s.length > 200 ? s.slice(0, 197) + '...' : s;
|
||||
}
|
||||
|
||||
export class Handlers {
|
||||
constructor(private readonly ctx: AppContext) {}
|
||||
|
||||
private providerName(explicit?: string): string {
|
||||
return explicit || this.ctx.defaultProvider;
|
||||
}
|
||||
|
||||
async handle(name: string, args: Record<string, unknown>): Promise<McpResult> {
|
||||
switch (name) {
|
||||
case 'nexus_tts':
|
||||
return this.media('tts', TtsSchema.parse(args));
|
||||
case 'nexus_image':
|
||||
return this.media('image', ImageSchema.parse(args));
|
||||
case 'nexus_video':
|
||||
return this.media('video', VideoSchema.parse(args));
|
||||
case 'nexus_music':
|
||||
return this.media('music', MusicSchema.parse(args));
|
||||
case 'nexus_stt':
|
||||
return this.stt(SttSchema.parse(args));
|
||||
case 'nexus_web_search':
|
||||
return this.search(WebSearchSchema.parse(args));
|
||||
case 'nexus_media_models':
|
||||
return this.models(MediaModelsSchema.parse(args));
|
||||
case 'nexus_media_estimate':
|
||||
return this.estimate(MediaEstimateSchema.parse(args));
|
||||
case 'nexus_media_upload':
|
||||
return this.upload(MediaUploadSchema.parse(args));
|
||||
case 'nexus_usage_stats':
|
||||
return this.usage(UsageStatsSchema.parse(args));
|
||||
case 'nexus_vision':
|
||||
return this.vision(VisionSchema.parse(args));
|
||||
default:
|
||||
throw new NexusError('NOT_FOUND', `Unknown tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async media(
|
||||
capability: Capability,
|
||||
args: { provider?: string; model: string; input: Record<string, unknown>; output?: { save?: boolean; embed?: boolean } }
|
||||
): Promise<McpResult> {
|
||||
const providerName = this.providerName(args.provider);
|
||||
const provider = this.ctx.registry.getMedia(providerName, capability);
|
||||
const started = Date.now();
|
||||
const id = randomUUID();
|
||||
|
||||
// Resolve the model against the synced catalog: exact, nearest-cheapest, or
|
||||
// a helpful error listing available models for the agent to relay.
|
||||
let model = args.model;
|
||||
let resolutionNote: string | undefined;
|
||||
const catalog = this.ctx.catalogs.get(providerName);
|
||||
if (catalog) {
|
||||
try {
|
||||
const r = await catalog.resolve(args.model, capability);
|
||||
model = r.model;
|
||||
resolutionNote = r.note;
|
||||
} catch (err) {
|
||||
if (err instanceof ModelResolutionError) {
|
||||
throw new NexusError('BAD_REQUEST', err.message, {
|
||||
capability: err.capability,
|
||||
requested: args.model,
|
||||
available: err.suggestions,
|
||||
hint: 'Ask the user to choose one of the available models (sorted cheapest first).',
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await provider.generate(capability, { model, input: args.input });
|
||||
this.ctx.journal.record({
|
||||
id,
|
||||
provider: providerName,
|
||||
capability,
|
||||
model,
|
||||
inputSummary: summarize(args.input),
|
||||
status: 'done',
|
||||
costUsd: result.costUsd ?? null,
|
||||
filePath: result.filePath ?? null,
|
||||
resultUrl: result.resultUrl ?? null,
|
||||
error: null,
|
||||
durationMs: Date.now() - started,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return this.mediaResult(result, args.output?.embed ?? false, resolutionNote);
|
||||
} catch (err) {
|
||||
const e = NexusError.from(err);
|
||||
this.ctx.journal.record({
|
||||
id,
|
||||
provider: providerName,
|
||||
capability,
|
||||
model,
|
||||
inputSummary: summarize(args.input),
|
||||
status: 'failed',
|
||||
costUsd: null,
|
||||
filePath: null,
|
||||
resultUrl: null,
|
||||
error: `${e.code}: ${e.message}`,
|
||||
durationMs: Date.now() - started,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private mediaResult(result: MediaResult, embed: boolean, note?: string): McpResult {
|
||||
const content: McpContent[] = [
|
||||
jsonBlock({
|
||||
provider: result.provider,
|
||||
capability: result.capability,
|
||||
model: result.model,
|
||||
status: result.status,
|
||||
file_path: result.filePath,
|
||||
mime_type: result.mimeType,
|
||||
result_url: result.resultUrl,
|
||||
result_url_2: result.resultUrl2,
|
||||
cost_usd: result.costUsd,
|
||||
...(note ? { note } : {}),
|
||||
}),
|
||||
];
|
||||
|
||||
if (embed && result.filePath) {
|
||||
const block = buildEmbeddedContent(result.filePath, result.mimeType, this.ctx.config.embedMaxBytes);
|
||||
if (block) content.push(block);
|
||||
else content.push({ type: 'text', text: '(embed skipped: file too large or not embeddable)' });
|
||||
}
|
||||
return { content };
|
||||
}
|
||||
|
||||
private async stt(args: {
|
||||
provider?: string;
|
||||
input: { audioPath?: string; audioUrl?: string; audioBase64?: string; language?: string; model?: string };
|
||||
}): Promise<McpResult> {
|
||||
const providerName = args.provider || 'local';
|
||||
const provider = this.ctx.registry.getStt(providerName);
|
||||
const started = Date.now();
|
||||
const id = randomUUID();
|
||||
try {
|
||||
const res = await provider.transcribe(args.input);
|
||||
// Persist the transcript as a Markdown file so it shows up in the gallery
|
||||
// and can be read in the modal viewer.
|
||||
const transcriptPath = this.writeTranscript(id, res, args.input);
|
||||
this.ctx.journal.record({
|
||||
id,
|
||||
provider: providerName,
|
||||
capability: 'stt',
|
||||
model: args.input.model || 'default',
|
||||
inputSummary: summarize({ ...args.input, audioBase64: args.input.audioBase64 ? '[base64]' : undefined }),
|
||||
status: 'done',
|
||||
costUsd: 0,
|
||||
filePath: transcriptPath,
|
||||
resultUrl: null,
|
||||
error: null,
|
||||
durationMs: Date.now() - started,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
jsonBlock({
|
||||
provider: res.provider,
|
||||
language: res.language,
|
||||
duration_sec: res.durationSec,
|
||||
text: res.text,
|
||||
segments: res.segments,
|
||||
}),
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
const e = NexusError.from(err);
|
||||
this.ctx.journal.record({
|
||||
id,
|
||||
provider: providerName,
|
||||
capability: 'stt',
|
||||
model: args.input.model || 'default',
|
||||
inputSummary: 'stt',
|
||||
status: 'failed',
|
||||
costUsd: null,
|
||||
filePath: null,
|
||||
resultUrl: null,
|
||||
error: `${e.code}: ${e.message}`,
|
||||
durationMs: Date.now() - started,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the STT transcript to a Markdown file in the media dir. Returns the path or null. */
|
||||
private writeTranscript(
|
||||
id: string,
|
||||
res: { text: string; language?: string; durationSec?: number; segments?: Array<{ start: number; end: number; text: string }> },
|
||||
input: { audioPath?: string; audioUrl?: string; model?: string }
|
||||
): string | null {
|
||||
try {
|
||||
const source = input.audioPath || input.audioUrl || '(inline audio)';
|
||||
const lines: string[] = [
|
||||
'# Транскрипция',
|
||||
'',
|
||||
`- **Источник:** ${source}`,
|
||||
`- **Язык:** ${res.language ?? 'auto'}`,
|
||||
`- **Длительность:** ${res.durationSec != null ? res.durationSec + ' сек' : '—'}`,
|
||||
`- **Модель:** ${input.model || 'default'}`,
|
||||
`- **Создано:** ${new Date().toISOString()}`,
|
||||
'',
|
||||
'## Текст',
|
||||
'',
|
||||
res.text || '_(пусто)_',
|
||||
];
|
||||
if (res.segments?.length) {
|
||||
lines.push('', '## Сегменты', '');
|
||||
for (const s of res.segments) {
|
||||
lines.push(`- \`[${s.start.toFixed(2)}–${s.end.toFixed(2)}]\` ${s.text.trim()}`);
|
||||
}
|
||||
}
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filePath = path.join(this.ctx.config.mediaDir, `transcript-${stamp}-${id.slice(0, 8)}.md`);
|
||||
fs.mkdirSync(this.ctx.config.mediaDir, { recursive: true });
|
||||
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
|
||||
return filePath;
|
||||
} catch (err) {
|
||||
log.warn('failed to write transcript', err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async search(args: {
|
||||
provider?: string;
|
||||
query: string;
|
||||
model?: string;
|
||||
recency?: 'day' | 'week' | 'month' | 'year';
|
||||
maxResults?: number;
|
||||
}): Promise<McpResult> {
|
||||
const providerName = this.providerName(args.provider);
|
||||
const provider = this.ctx.registry.getSearch(providerName);
|
||||
const started = Date.now();
|
||||
const id = randomUUID();
|
||||
const res = await provider.search({
|
||||
query: args.query,
|
||||
model: args.model,
|
||||
recency: args.recency,
|
||||
maxResults: args.maxResults,
|
||||
});
|
||||
this.ctx.journal.record({
|
||||
id,
|
||||
provider: providerName,
|
||||
capability: 'search',
|
||||
model: res.model,
|
||||
inputSummary: summarize({ query: args.query }),
|
||||
status: 'done',
|
||||
costUsd: res.costUsd ?? null,
|
||||
filePath: null,
|
||||
resultUrl: null,
|
||||
error: null,
|
||||
durationMs: Date.now() - started,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return {
|
||||
content: [jsonBlock({ provider: res.provider, model: res.model, answer: res.answer, citations: res.citations })],
|
||||
};
|
||||
}
|
||||
|
||||
private async models(args: { provider?: string; type?: string; search?: string }): Promise<McpResult> {
|
||||
const providerName = this.providerName(args.provider);
|
||||
// any capability that this provider supports is fine to fetch the catalog
|
||||
const provider = this.ctx.registry.getMedia(providerName, 'image');
|
||||
if (!provider.listModels) throw new NexusError('UNSUPPORTED', `Provider ${providerName} cannot list models`);
|
||||
let models = await provider.listModels();
|
||||
if (args.type) models = models.filter((m) => m.type === args.type);
|
||||
if (args.search) {
|
||||
const q = args.search.toLowerCase();
|
||||
models = models.filter((m) => m.id.toLowerCase().includes(q) || (m.label || '').toLowerCase().includes(q));
|
||||
}
|
||||
return { content: [jsonBlock({ count: models.length, models })] };
|
||||
}
|
||||
|
||||
private async estimate(args: { provider?: string; model: string; input: Record<string, unknown> }): Promise<McpResult> {
|
||||
const providerName = this.providerName(args.provider);
|
||||
const provider = this.ctx.registry.getMedia(providerName, 'image');
|
||||
if (!provider.estimate) throw new NexusError('UNSUPPORTED', `Provider ${providerName} cannot estimate`);
|
||||
const est = await provider.estimate({ model: args.model, input: args.input });
|
||||
return { content: [jsonBlock({ model: args.model, usd: est.usd })] };
|
||||
}
|
||||
|
||||
private async upload(args: { provider?: string; path: string; name?: string; mimeType?: string }): Promise<McpResult> {
|
||||
const providerName = this.providerName(args.provider);
|
||||
const provider = this.ctx.registry.getMedia(providerName, 'image');
|
||||
if (!provider.upload) throw new NexusError('UNSUPPORTED', `Provider ${providerName} cannot upload`);
|
||||
const res = await provider.upload({ path: args.path, name: args.name, mimeType: args.mimeType });
|
||||
return { content: [jsonBlock({ url: res.url, ref: res.ref })] };
|
||||
}
|
||||
|
||||
private async usage(args: { sinceDays?: number; list?: boolean; limit: number }): Promise<McpResult> {
|
||||
const stats = this.ctx.journal.stats(args.sinceDays);
|
||||
const payload: Record<string, unknown> = { stats };
|
||||
if (args.list) payload.recent = this.ctx.journal.list(args.limit);
|
||||
log.debug('usage stats requested');
|
||||
return { content: [jsonBlock(payload)] };
|
||||
}
|
||||
|
||||
private async vision(args: {
|
||||
provider?: string;
|
||||
image: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
}): Promise<McpResult> {
|
||||
const providerName = this.providerName(args.provider);
|
||||
const row = this.ctx.providersStore.get(providerName);
|
||||
const apiKey = this.ctx.providersStore.getApiKey(providerName);
|
||||
const baseUrl = row?.baseUrl || process.env[`${providerName.toUpperCase()}_BASE_URL`] || 'https://nordrouter.com';
|
||||
if (!apiKey) {
|
||||
throw new NexusError('CONFIG', `No API key for provider "${providerName}"`);
|
||||
}
|
||||
|
||||
let imageUrl = args.image;
|
||||
if (!args.image.startsWith('http') && !args.image.startsWith('data:')) {
|
||||
if (!fs.existsSync(args.image)) {
|
||||
throw new NexusError('IO', `Image file not found: ${args.image}`);
|
||||
}
|
||||
const buf = fs.readFileSync(args.image);
|
||||
const ext = args.image.split('.').pop()?.toLowerCase() || 'png';
|
||||
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : 'image/png';
|
||||
imageUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
||||
}
|
||||
|
||||
const client = new NordRouterClient(apiKey, baseUrl);
|
||||
const started = Date.now();
|
||||
const id = randomUUID();
|
||||
const model = args.model || 'google/gemini-3.1-flash-lite';
|
||||
|
||||
try {
|
||||
const res = await client.http.requestJson<{
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
usage?: { total_cost?: number };
|
||||
}>({
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
body: {
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: args.prompt },
|
||||
{ type: 'image_url', image_url: { url: imageUrl } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const text = res.choices?.[0]?.message?.content?.trim() || '';
|
||||
this.ctx.journal.record({
|
||||
id,
|
||||
provider: providerName,
|
||||
capability: 'vision',
|
||||
model,
|
||||
inputSummary: summarize({ prompt: args.prompt, image: args.image }),
|
||||
status: 'done',
|
||||
costUsd: res.usage?.total_cost ?? null,
|
||||
filePath: null,
|
||||
resultUrl: null,
|
||||
error: null,
|
||||
durationMs: Date.now() - started,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return { content: [jsonBlock({ provider: providerName, model, description: text })] };
|
||||
} catch (err) {
|
||||
const e = NexusError.from(err);
|
||||
this.ctx.journal.record({
|
||||
id,
|
||||
provider: providerName,
|
||||
capability: 'vision',
|
||||
model,
|
||||
inputSummary: summarize({ prompt: args.prompt, image: args.image }),
|
||||
status: 'failed',
|
||||
costUsd: null,
|
||||
filePath: null,
|
||||
resultUrl: null,
|
||||
error: `${e.code}: ${e.message}`,
|
||||
durationMs: Date.now() - started,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
type Tool,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { NexusError, createLogger } from '@nexusai/core';
|
||||
import { buildContext } from './context.js';
|
||||
import { Handlers } from './handlers.js';
|
||||
import { TOOLS } from './tools.js';
|
||||
|
||||
const log = createLogger('server');
|
||||
|
||||
function main(): void {
|
||||
const ctx = buildContext();
|
||||
const handlers = new Handlers(ctx);
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'nexusai-mcp-server', version: '0.1.0' },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: TOOLS.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
})) as Tool[],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
try {
|
||||
return (await handlers.handle(name, args ?? {})) as never;
|
||||
} catch (error) {
|
||||
const e = NexusError.from(error);
|
||||
log.error(`tool ${name} failed`, `${e.code}: ${e.message}`);
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error [${e.code}]: ${e.message}` }],
|
||||
isError: true,
|
||||
} as never;
|
||||
}
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
server.connect(transport).catch((error: unknown) => {
|
||||
log.error('failed to start MCP server', error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
log.info(`NexusAI MCP server started with ${TOOLS.length} tools`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,151 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Common output options for media tools. */
|
||||
const OutputOptions = z
|
||||
.object({
|
||||
save: z.boolean().default(true).describe('Save the result to a local file.'),
|
||||
embed: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe('Also embed image/audio directly in the MCP result (size-limited).'),
|
||||
})
|
||||
.partial()
|
||||
.default({});
|
||||
|
||||
const provider = z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Provider name. Defaults to the configured default provider (e.g. "nordrouter").');
|
||||
|
||||
export const TtsSchema = z.object({
|
||||
provider,
|
||||
model: z
|
||||
.string()
|
||||
.default('audio/elevenlabs-tts')
|
||||
.describe('TTS model id, e.g. audio/elevenlabs-tts, audio/elevenlabs-multilingual, audio/gemini-3.1-flash-tts.'),
|
||||
input: z
|
||||
.object({
|
||||
text: z.string().describe('Text to synthesize.'),
|
||||
voice: z.string().optional().describe('Voice name (see nexus_media_models for options).'),
|
||||
voice_id: z.string().optional(),
|
||||
speed: z.union([z.string(), z.number()]).optional().describe('Speed, e.g. "0.8" | "1.0" | "1.2".'),
|
||||
})
|
||||
.passthrough()
|
||||
.describe('Provider input. Extra model-specific fields are passed through.'),
|
||||
output: OutputOptions,
|
||||
});
|
||||
|
||||
export const ImageSchema = z.object({
|
||||
provider,
|
||||
model: z
|
||||
.string()
|
||||
.default('image/nano-banana-2')
|
||||
.describe('Image model id, e.g. image/nano-banana-2, image/flux2-pro, image/gpt-image-2, image/z-image. Use *-edit models for editing.'),
|
||||
input: z
|
||||
.object({
|
||||
prompt: z.string().describe('Image description. For edits also pass "image".'),
|
||||
image: z.string().optional().describe('Source image URL for edit/i2i models.'),
|
||||
aspect_ratio: z.string().optional(),
|
||||
resolution: z.string().optional().describe('1K | 2K | 4K (model dependent).'),
|
||||
})
|
||||
.passthrough(),
|
||||
output: OutputOptions,
|
||||
});
|
||||
|
||||
export const VideoSchema = z.object({
|
||||
provider,
|
||||
model: z
|
||||
.string()
|
||||
.default('video/kling-2.6-t2v')
|
||||
.describe('Video model id, e.g. video/kling-2.6-t2v, video/veo-3.1-fast, video/seedance-2. Use i2v models with an "image".'),
|
||||
input: z
|
||||
.object({
|
||||
prompt: z.string().describe('Video description.'),
|
||||
image: z.string().optional().describe('First-frame image URL for i2v models.'),
|
||||
image2: z.string().optional().describe('Last-frame image URL (transition models).'),
|
||||
duration: z.union([z.string(), z.number()]).optional(),
|
||||
resolution: z.string().optional(),
|
||||
aspect_ratio: z.string().optional(),
|
||||
sound: z.boolean().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
output: OutputOptions,
|
||||
});
|
||||
|
||||
export const MusicSchema = z.object({
|
||||
provider,
|
||||
model: z
|
||||
.string()
|
||||
.default('music/suno-v5')
|
||||
.describe('Music model id, e.g. music/suno-v5, music/suno-cover, music/suno-sounds.'),
|
||||
input: z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
style: z.string().optional(),
|
||||
prompt: z.string().optional().describe('Lyrics/prompt.'),
|
||||
instrumental: z.boolean().optional(),
|
||||
version: z.string().optional().describe('V4 | V4_5 | V4_5PLUS | V4_5ALL | V5 | V5_5.'),
|
||||
})
|
||||
.passthrough(),
|
||||
output: OutputOptions,
|
||||
});
|
||||
|
||||
export const SttSchema = z.object({
|
||||
provider: z.string().optional().describe('STT provider. Defaults to "local" (faster-whisper).'),
|
||||
input: z.object({
|
||||
audioPath: z.string().optional().describe('Local audio file path.'),
|
||||
audioUrl: z.string().optional().describe('Remote audio URL.'),
|
||||
audioBase64: z.string().optional().describe('Base64 or data-URL audio.'),
|
||||
language: z.string().optional().describe('Language hint, e.g. "ru", "en". Auto-detect if omitted.'),
|
||||
model: z.string().optional().describe('Whisper size: tiny|base|small|medium|large-v3.'),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WebSearchSchema = z.object({
|
||||
provider,
|
||||
query: z.string().describe('Search query / question.'),
|
||||
model: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Search model, e.g. perplexity/sonar, perplexity/sonar-pro, or search/<model>.'),
|
||||
recency: z.enum(['day', 'week', 'month', 'year']).optional(),
|
||||
maxResults: z.number().int().positive().max(50).optional(),
|
||||
});
|
||||
|
||||
export const VisionSchema = z.object({
|
||||
provider,
|
||||
image: z.string().describe('Image URL, local file path, or base64/data-URL.'),
|
||||
prompt: z
|
||||
.string()
|
||||
.default('Describe the scene in this image in detail, as if writing a prompt for video generation.')
|
||||
.describe('What to ask the vision model about the image.'),
|
||||
model: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Vision-capable chat model, e.g. google/gemini-3.1-flash-lite, openai/gpt-5.4-mini.'),
|
||||
});
|
||||
|
||||
export const MediaModelsSchema = z.object({
|
||||
provider,
|
||||
type: z.enum(['image', 'video', 'music', 'audio', 'upscale']).optional().describe('Filter by model type.'),
|
||||
search: z.string().optional().describe('Substring filter on model id/label.'),
|
||||
});
|
||||
|
||||
export const MediaEstimateSchema = z.object({
|
||||
provider,
|
||||
model: z.string().describe('Model id to estimate.'),
|
||||
input: z.record(z.unknown()).default({}).describe('Same input you would pass to generate.'),
|
||||
});
|
||||
|
||||
export const MediaUploadSchema = z.object({
|
||||
provider,
|
||||
path: z.string().describe('Local file path to upload.'),
|
||||
name: z.string().optional(),
|
||||
mimeType: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UsageStatsSchema = z.object({
|
||||
sinceDays: z.number().int().positive().optional().describe('Limit stats to the last N days.'),
|
||||
list: z.boolean().default(false).describe('Also return recent generations.'),
|
||||
limit: z.number().int().positive().max(200).default(20),
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import {
|
||||
ImageSchema,
|
||||
MediaEstimateSchema,
|
||||
MediaModelsSchema,
|
||||
MediaUploadSchema,
|
||||
MusicSchema,
|
||||
SttSchema,
|
||||
TtsSchema,
|
||||
UsageStatsSchema,
|
||||
VideoSchema,
|
||||
VisionSchema,
|
||||
WebSearchSchema,
|
||||
} from './schemas.js';
|
||||
|
||||
export interface ToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: object;
|
||||
}
|
||||
|
||||
function make(name: string, description: string, schema: unknown): ToolDef {
|
||||
const json = zodToJsonSchema(schema as any, { name, $refStrategy: 'none' }) as any;
|
||||
const def = json.definitions?.[name] ?? json;
|
||||
return { name, description, inputSchema: def };
|
||||
}
|
||||
|
||||
export const TOOLS: ToolDef[] = [
|
||||
make(
|
||||
'nexus_tts',
|
||||
'Text-to-speech. Generates spoken audio from text via a provider. Returns a saved audio file path and optional embedded audio.',
|
||||
TtsSchema
|
||||
),
|
||||
make(
|
||||
'nexus_image',
|
||||
'Generate or edit images from a text prompt (and optional source image URL). Returns a saved image file and optional embedded image.',
|
||||
ImageSchema
|
||||
),
|
||||
make(
|
||||
'nexus_video',
|
||||
'Generate video from text or an image (t2v / i2v). Async, may take minutes. Returns a saved MP4 file path.',
|
||||
VideoSchema
|
||||
),
|
||||
make(
|
||||
'nexus_music',
|
||||
'Generate music from a style/prompt. Returns saved audio file(s); some models produce two tracks.',
|
||||
MusicSchema
|
||||
),
|
||||
make(
|
||||
'nexus_stt',
|
||||
'Speech-to-text. Transcribe local/remote/base64 audio using a local whisper model. Returns text, language and segments.',
|
||||
SttSchema
|
||||
),
|
||||
make(
|
||||
'nexus_web_search',
|
||||
'Web search that returns an answer with cited source URLs (via search-augmented models).',
|
||||
WebSearchSchema
|
||||
),
|
||||
make(
|
||||
'nexus_media_models',
|
||||
'List available media models (id, type, label, estimated price) for a provider. Use to discover valid model ids and parameters.',
|
||||
MediaModelsSchema
|
||||
),
|
||||
make(
|
||||
'nexus_media_estimate',
|
||||
'Estimate the USD cost of a media generation before running it.',
|
||||
MediaEstimateSchema
|
||||
),
|
||||
make(
|
||||
'nexus_media_upload',
|
||||
'Upload a local media file to the provider and get a reference URL for use as input (image/audio/video).',
|
||||
MediaUploadSchema
|
||||
),
|
||||
make(
|
||||
'nexus_usage_stats',
|
||||
'Show usage and spend recorded in the local journal (totals by provider, capability, day) and optionally recent generations.',
|
||||
UsageStatsSchema
|
||||
),
|
||||
make(
|
||||
'nexus_vision',
|
||||
'Describe an image using a vision-capable chat model. Returns a text description useful for prompts.',
|
||||
VisionSchema
|
||||
),
|
||||
];
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# dependencies = [
|
||||
# "faster-whisper>=1.0.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
NexusAI local STT sidecar (faster-whisper).
|
||||
|
||||
Invoked by the TypeScript MCP server as:
|
||||
uv run sidecar/stt_server.py '<json-request>'
|
||||
|
||||
Request JSON:
|
||||
{ "audio": "/path/to/file", "model": "small", "device": "auto",
|
||||
"compute_type": "auto", "language": "ru" }
|
||||
|
||||
Prints exactly ONE JSON line on stdout:
|
||||
{ "ok": true, "text": "...", "language": "ru", "duration": 12.3,
|
||||
"segments": [ { "start": 0.0, "end": 2.1, "text": "..." } ] }
|
||||
On error:
|
||||
{ "ok": false, "error": "message" }
|
||||
|
||||
All diagnostics go to stderr so stdout stays a clean single JSON line.
|
||||
faster-whisper decodes audio via bundled ffmpeg/pyav, so no manual conversion
|
||||
is required for common formats (mp3/wav/ogg/m4a/webm...).
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def resolve_device(device: str, compute_type: str):
|
||||
if device and device != "auto":
|
||||
ct = compute_type if compute_type and compute_type != "auto" else (
|
||||
"float16" if device == "cuda" else "int8"
|
||||
)
|
||||
return device, ct
|
||||
# auto-detect: try cuda, fall back to cpu
|
||||
try:
|
||||
import ctranslate2 # noqa: F401
|
||||
cuda_count = 0
|
||||
try:
|
||||
cuda_count = ctranslate2.get_cuda_device_count()
|
||||
except Exception:
|
||||
cuda_count = 0
|
||||
if cuda_count > 0:
|
||||
return "cuda", (compute_type if compute_type != "auto" else "float16")
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu", (compute_type if compute_type != "auto" else "int8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"ok": False, "error": "missing request argument"}))
|
||||
return 1
|
||||
|
||||
try:
|
||||
req = json.loads(sys.argv[1])
|
||||
except Exception as exc:
|
||||
print(json.dumps({"ok": False, "error": f"invalid request json: {exc}"}))
|
||||
return 1
|
||||
|
||||
audio = req.get("audio")
|
||||
if not audio:
|
||||
print(json.dumps({"ok": False, "error": "no audio path provided"}))
|
||||
return 1
|
||||
|
||||
model_size = req.get("model") or "small"
|
||||
device, compute_type = resolve_device(req.get("device", "auto"), req.get("compute_type", "auto"))
|
||||
language = req.get("language") or None
|
||||
|
||||
try:
|
||||
from faster_whisper import WhisperModel
|
||||
except Exception as exc:
|
||||
print(json.dumps({"ok": False, "error": f"faster-whisper not available: {exc}"}))
|
||||
return 1
|
||||
|
||||
# Allow pointing at a local model dir (offline) or a mirror via env.
|
||||
# NEXUS_STT_MODEL may be a whisper size name OR an absolute path to a
|
||||
# pre-downloaded CTranslate2 model directory.
|
||||
import os
|
||||
model_ref = model_size
|
||||
local_only = os.environ.get("NEXUS_STT_LOCAL_ONLY", "").lower() in ("1", "true", "yes")
|
||||
download_root = os.environ.get("NEXUS_STT_DOWNLOAD_ROOT") or None
|
||||
|
||||
try:
|
||||
log(f"loading model={model_ref} device={device} compute={compute_type} local_only={local_only}")
|
||||
model = WhisperModel(
|
||||
model_ref,
|
||||
device=device,
|
||||
compute_type=compute_type,
|
||||
download_root=download_root,
|
||||
local_files_only=local_only,
|
||||
)
|
||||
segments_iter, info = model.transcribe(audio, language=language, vad_filter=True)
|
||||
|
||||
segments = []
|
||||
parts = []
|
||||
for seg in segments_iter:
|
||||
parts.append(seg.text)
|
||||
segments.append({"start": round(seg.start, 3), "end": round(seg.end, 3), "text": seg.text.strip()})
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"text": "".join(parts).strip(),
|
||||
"language": info.language,
|
||||
"duration": round(getattr(info, "duration", 0.0) or 0.0, 3),
|
||||
"segments": segments,
|
||||
}
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
except Exception as exc:
|
||||
hint = ""
|
||||
msg = str(exc)
|
||||
if "connect" in msg.lower() or "internet" in msg.lower() or "Hub" in msg:
|
||||
hint = (
|
||||
" | Model download failed. Options: set HF_ENDPOINT to a reachable mirror, "
|
||||
"pre-download the model and set NEXUS_STT_MODEL to its local path with "
|
||||
"NEXUS_STT_LOCAL_ONLY=1, or set NEXUS_STT_DOWNLOAD_ROOT to a cache dir."
|
||||
)
|
||||
print(json.dumps({"ok": False, "error": f"transcription failed: {exc}{hint}"}, ensure_ascii=False))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user