feat: STT transcripts, model catalog+fuzzy resolve, UI audio player & auto-refresh
Core: - ModelCatalog: periodic background sync of provider models + fuzzy resolve. Misspelled model ids map to the nearest match (cheapest on ties); no match raises ModelResolutionError with cheapest-first suggestions for the agent. mcp-server: - nexus_stt now writes the transcript to a Markdown file in the media dir so it appears in the gallery and is readable in the modal. - media handler resolves model via catalog before generating; returns a note when substituting, or a BAD_REQUEST with available models when nothing fits. - context wires a ModelCatalog per media provider with auto-sync. admin: - /media serves correct Content-Type (incl. text/markdown for transcripts). - Gallery: audio play button on cards, dedicated audio player in modal, text/transcript tiles that fetch and render the .md in the modal. - Header: auto-refresh toggle (5/10/30s) that refreshes the active tab without a full page reload; pauses while a modal is open.
This commit is contained in:
@@ -0,0 +1,372 @@
|
|||||||
|
# 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`.
|
||||||
@@ -41,18 +41,46 @@ $('#themeToggle').addEventListener('click', () => {
|
|||||||
applyTheme(localStorage.getItem('nexus-theme') || 'dark');
|
applyTheme(localStorage.getItem('nexus-theme') || 'dark');
|
||||||
|
|
||||||
// --- Tabs ---
|
// --- 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) => {
|
$$('nav button').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
$$('nav button').forEach((b) => b.classList.remove('active'));
|
$$('nav button').forEach((b) => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
$$('main section').forEach((s) => s.classList.add('hidden'));
|
$$('main section').forEach((s) => s.classList.add('hidden'));
|
||||||
$(`#tab-${btn.dataset.tab}`).classList.remove('hidden');
|
$(`#tab-${btn.dataset.tab}`).classList.remove('hidden');
|
||||||
if (btn.dataset.tab === 'usage') loadStats();
|
activeTab = btn.dataset.tab;
|
||||||
if (btn.dataset.tab === 'gallery') loadGallery();
|
refreshActiveTab();
|
||||||
if (btn.dataset.tab === 'providers') loadProviders();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 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 ---
|
// --- Usage ---
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
const range = $('#statsRange').value;
|
const range = $('#statsRange').value;
|
||||||
@@ -98,19 +126,33 @@ async function loadGallery() {
|
|||||||
|
|
||||||
$$('[data-view]').forEach((el) => el.onclick = () => openModal(el.dataset.view));
|
$$('[data-view]').forEach((el) => el.onclick = () => openModal(el.dataset.view));
|
||||||
$$('[data-del]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); delGeneration(b.dataset.del); });
|
$$('[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) {
|
function renderTile(it) {
|
||||||
const cost = it.costUsd != null ? `$${usd(it.costUsd)}` : '';
|
const cost = it.costUsd != null ? `$${usd(it.costUsd)}` : '';
|
||||||
let media = '';
|
let media = '';
|
||||||
if (it.mediaUrl) {
|
if (it.mediaUrl) {
|
||||||
if (it.capability === 'image') media = `<div class="thumb" data-view="${it.id}"><img src="${it.mediaUrl}" loading="lazy" /></div>`;
|
if (it.capability === 'image') {
|
||||||
else if (it.capability === 'tts' || it.capability === 'music') media = `<div style="padding:10px"><audio controls src="${it.mediaUrl}"></audio></div>`;
|
media = `<div class="thumb" data-view="${it.id}"><img src="${it.mediaUrl}" loading="lazy" /></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 (isAudio(it)) {
|
||||||
else media = `<a href="${it.mediaUrl}" target="_blank" style="display:block;padding:20px;text-align:center">Открыть файл</a>`;
|
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 {
|
} else {
|
||||||
const status = it.status === 'failed' ? '⚠ ошибка' : 'нет локального файла';
|
const status = it.status === 'failed' ? '⚠ ошибка' : 'нет локального файла';
|
||||||
media = `<div style="height:150px;display:flex;align-items:center;justify-content:center" class="muted">${status}</div>`;
|
media = `<div class="thumb text-thumb ${it.status === 'failed' ? 'err' : ''}" data-view="${it.id}">
|
||||||
|
<span class="muted">${status}</span></div>`;
|
||||||
}
|
}
|
||||||
return `<div class="tile">
|
return `<div class="tile">
|
||||||
<button class="del" data-del="${it.id}" title="Удалить">✕</button>
|
<button class="del" data-del="${it.id}" title="Удалить">✕</button>
|
||||||
@@ -143,10 +185,23 @@ async function openModal(id) {
|
|||||||
catch (e) { return toast('Ошибка: ' + e.message); }
|
catch (e) { return toast('Ошибка: ' + e.message); }
|
||||||
|
|
||||||
let body = '';
|
let body = '';
|
||||||
if (rec.mediaUrl && rec.capability === 'image') body = `<img src="${rec.mediaUrl}" />`;
|
if (rec.mediaUrl && rec.capability === 'image') {
|
||||||
else if (rec.mediaUrl && rec.capability === 'video') body = `<video controls autoplay src="${rec.mediaUrl}"></video>`;
|
body = `<img src="${rec.mediaUrl}" />`;
|
||||||
else if (rec.mediaUrl && (rec.capability === 'tts' || rec.capability === 'music')) body = `<audio controls autoplay src="${rec.mediaUrl}"></audio>`;
|
} else if (rec.mediaUrl && rec.capability === 'video') {
|
||||||
else body = `<div class="muted">Нет предпросмотра для этого типа.</div>`;
|
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 = `
|
const kv = `
|
||||||
<div class="kv">Модель: ${esc(rec.model)}</div>
|
<div class="kv">Модель: ${esc(rec.model)}</div>
|
||||||
|
|||||||
@@ -85,6 +85,26 @@
|
|||||||
.modal-body img, .modal-body video { max-width: 88vw; max-height: 72vh; display: block; margin: 0 auto; }
|
.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; }
|
.modal-body audio { width: 60vw; max-width: 600px; }
|
||||||
.kv { color: var(--muted); font-size: 12px; margin-top: 10px; word-break: break-all; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -92,6 +112,14 @@
|
|||||||
<h1>NexusAI Admin</h1>
|
<h1>NexusAI Admin</h1>
|
||||||
<span class="badge" id="cryptoBadge"></span>
|
<span class="badge" id="cryptoBadge"></span>
|
||||||
<span class="spacer"></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>
|
<button id="themeToggle" title="Переключить тему">🌙 Тёмная</button>
|
||||||
</header>
|
</header>
|
||||||
<nav>
|
<nav>
|
||||||
@@ -141,6 +169,7 @@
|
|||||||
<option value="music">music</option>
|
<option value="music">music</option>
|
||||||
<option value="video">video</option>
|
<option value="video">video</option>
|
||||||
<option value="stt">stt</option>
|
<option value="stt">stt</option>
|
||||||
|
<option value="vision">vision</option>
|
||||||
<option value="search">search</option>
|
<option value="search">search</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ async function main(): Promise<void> {
|
|||||||
if (!full.startsWith(mediaDir + path.sep) || !fs.existsSync(full)) {
|
if (!full.startsWith(mediaDir + path.sep) || !fs.existsSync(full)) {
|
||||||
return reply.code(404).send({ error: 'not found' });
|
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);
|
const stream = fs.createReadStream(full);
|
||||||
reply.header('Cache-Control', 'private, max-age=60');
|
reply.header('Cache-Control', 'private, max-age=60');
|
||||||
return reply.send(stream);
|
return reply.send(stream);
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ export * from './media/storage.js';
|
|||||||
export * from './media/content.js';
|
export * from './media/content.js';
|
||||||
export * from './providers/types.js';
|
export * from './providers/types.js';
|
||||||
export * from './providers/registry.js';
|
export * from './providers/registry.js';
|
||||||
|
export * from './providers/model-catalog.js';
|
||||||
export * from './providers/nordrouter/index.js';
|
export * from './providers/nordrouter/index.js';
|
||||||
export * from './providers/local-stt/whisper.js';
|
export * from './providers/local-stt/whisper.js';
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
KeyCipher,
|
KeyCipher,
|
||||||
KeyResolver,
|
KeyResolver,
|
||||||
MediaStorage,
|
MediaStorage,
|
||||||
|
ModelCatalog,
|
||||||
ProviderRegistry,
|
ProviderRegistry,
|
||||||
ProvidersStore,
|
ProvidersStore,
|
||||||
createNordRouterProviders,
|
createNordRouterProviders,
|
||||||
@@ -20,6 +21,8 @@ export interface AppContext {
|
|||||||
storage: MediaStorage;
|
storage: MediaStorage;
|
||||||
journal: Journal;
|
journal: Journal;
|
||||||
providersStore: ProvidersStore;
|
providersStore: ProvidersStore;
|
||||||
|
/** Model catalogs keyed by provider name (for sync + fuzzy resolution). */
|
||||||
|
catalogs: Map<string, ModelCatalog>;
|
||||||
/** Effective default provider name after resolving DB/env. */
|
/** Effective default provider name after resolving DB/env. */
|
||||||
defaultProvider: string;
|
defaultProvider: string;
|
||||||
}
|
}
|
||||||
@@ -47,6 +50,7 @@ export function buildContext(): AppContext {
|
|||||||
|
|
||||||
const resolved = resolver.resolveAll(BUILTINS);
|
const resolved = resolver.resolveAll(BUILTINS);
|
||||||
const defaultProvider = resolver.resolveDefault(resolved);
|
const defaultProvider = resolver.resolveDefault(resolved);
|
||||||
|
const catalogs = new Map<string, ModelCatalog>();
|
||||||
|
|
||||||
for (const p of resolved) {
|
for (const p of resolved) {
|
||||||
if (!p.enabled) {
|
if (!p.enabled) {
|
||||||
@@ -69,6 +73,12 @@ export function buildContext(): AppContext {
|
|||||||
Object.defineProperty(nr.search, 'name', { value: p.name });
|
Object.defineProperty(nr.search, 'name', { value: p.name });
|
||||||
registry.registerMedia(nr.media);
|
registry.registerMedia(nr.media);
|
||||||
registry.registerSearch(nr.search);
|
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})`);
|
log.info(`registered provider "${p.name}" (media + search, key: ${p.keySource})`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(`failed to register "${p.name}"`, err instanceof Error ? err.message : String(err));
|
log.warn(`failed to register "${p.name}"`, err instanceof Error ? err.message : String(err));
|
||||||
@@ -82,5 +92,5 @@ export function buildContext(): AppContext {
|
|||||||
log.info('registered local STT provider');
|
log.info('registered local STT provider');
|
||||||
log.info(`default provider: ${defaultProvider}`);
|
log.info(`default provider: ${defaultProvider}`);
|
||||||
|
|
||||||
return { config, registry, storage, journal, providersStore, defaultProvider };
|
return { config, registry, storage, journal, providersStore, catalogs, defaultProvider };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import {
|
import {
|
||||||
|
ModelResolutionError,
|
||||||
NexusError,
|
NexusError,
|
||||||
NordRouterClient,
|
NordRouterClient,
|
||||||
buildEmbeddedContent,
|
buildEmbeddedContent,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
type MediaResult,
|
type MediaResult,
|
||||||
} from '@nexusai/core';
|
} from '@nexusai/core';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
import type { AppContext } from './context.js';
|
import type { AppContext } from './context.js';
|
||||||
import {
|
import {
|
||||||
ImageSchema,
|
ImageSchema,
|
||||||
@@ -89,13 +91,36 @@ export class Handlers {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const id = randomUUID();
|
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 {
|
try {
|
||||||
const result = await provider.generate(capability, { model: args.model, input: args.input });
|
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({
|
this.ctx.journal.record({
|
||||||
id,
|
id,
|
||||||
provider: providerName,
|
provider: providerName,
|
||||||
capability,
|
capability,
|
||||||
model: args.model,
|
model,
|
||||||
inputSummary: summarize(args.input),
|
inputSummary: summarize(args.input),
|
||||||
status: 'done',
|
status: 'done',
|
||||||
costUsd: result.costUsd ?? null,
|
costUsd: result.costUsd ?? null,
|
||||||
@@ -105,14 +130,14 @@ export class Handlers {
|
|||||||
durationMs: Date.now() - started,
|
durationMs: Date.now() - started,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
return this.mediaResult(result, args.output?.embed ?? false);
|
return this.mediaResult(result, args.output?.embed ?? false, resolutionNote);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = NexusError.from(err);
|
const e = NexusError.from(err);
|
||||||
this.ctx.journal.record({
|
this.ctx.journal.record({
|
||||||
id,
|
id,
|
||||||
provider: providerName,
|
provider: providerName,
|
||||||
capability,
|
capability,
|
||||||
model: args.model,
|
model,
|
||||||
inputSummary: summarize(args.input),
|
inputSummary: summarize(args.input),
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
costUsd: null,
|
costUsd: null,
|
||||||
@@ -126,7 +151,7 @@ export class Handlers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private mediaResult(result: MediaResult, embed: boolean): McpResult {
|
private mediaResult(result: MediaResult, embed: boolean, note?: string): McpResult {
|
||||||
const content: McpContent[] = [
|
const content: McpContent[] = [
|
||||||
jsonBlock({
|
jsonBlock({
|
||||||
provider: result.provider,
|
provider: result.provider,
|
||||||
@@ -138,6 +163,7 @@ export class Handlers {
|
|||||||
result_url: result.resultUrl,
|
result_url: result.resultUrl,
|
||||||
result_url_2: result.resultUrl2,
|
result_url_2: result.resultUrl2,
|
||||||
cost_usd: result.costUsd,
|
cost_usd: result.costUsd,
|
||||||
|
...(note ? { note } : {}),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -159,6 +185,9 @@ export class Handlers {
|
|||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
try {
|
try {
|
||||||
const res = await provider.transcribe(args.input);
|
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({
|
this.ctx.journal.record({
|
||||||
id,
|
id,
|
||||||
provider: providerName,
|
provider: providerName,
|
||||||
@@ -167,8 +196,7 @@ export class Handlers {
|
|||||||
inputSummary: summarize({ ...args.input, audioBase64: args.input.audioBase64 ? '[base64]' : undefined }),
|
inputSummary: summarize({ ...args.input, audioBase64: args.input.audioBase64 ? '[base64]' : undefined }),
|
||||||
status: 'done',
|
status: 'done',
|
||||||
costUsd: 0,
|
costUsd: 0,
|
||||||
// STT does not produce a local media file; the source audio path stays in inputSummary.
|
filePath: transcriptPath,
|
||||||
filePath: null,
|
|
||||||
resultUrl: null,
|
resultUrl: null,
|
||||||
error: null,
|
error: null,
|
||||||
durationMs: Date.now() - started,
|
durationMs: Date.now() - started,
|
||||||
@@ -205,6 +233,44 @@ export class Handlers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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: {
|
private async search(args: {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
query: string;
|
query: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user