diff --git a/NexusAI/TEST_RESUME.md b/NexusAI/TEST_RESUME.md new file mode 100644 index 0000000..cda6539 --- /dev/null +++ b/NexusAI/TEST_RESUME.md @@ -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`. diff --git a/NexusAI/packages/admin/public/app.js b/NexusAI/packages/admin/public/app.js index 5e68604..7fbeecc 100644 --- a/NexusAI/packages/admin/public/app.js +++ b/NexusAI/packages/admin/public/app.js @@ -41,18 +41,46 @@ $('#themeToggle').addEventListener('click', () => { 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'); - if (btn.dataset.tab === 'usage') loadStats(); - if (btn.dataset.tab === 'gallery') loadGallery(); - if (btn.dataset.tab === 'providers') loadProviders(); + 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; @@ -98,19 +126,33 @@ async function loadGallery() { $$('[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 = `
`; - else if (it.capability === 'tts' || it.capability === 'music') media = `
`; - else if (it.capability === 'video') media = `
`; - else media = `Открыть файл`; + if (it.capability === 'image') { + media = `
`; + } else if (isAudio(it)) { + media = `
+ +
`; + } else if (it.capability === 'video') { + media = `
`; + } else if (isText(it)) { + media = `
📄
`; + } else { + media = `Открыть файл`; + } } else { const status = it.status === 'failed' ? '⚠ ошибка' : 'нет локального файла'; - media = `
${status}
`; + media = `
+ ${status}
`; } return `
@@ -143,10 +185,23 @@ async function openModal(id) { catch (e) { return toast('Ошибка: ' + e.message); } let body = ''; - if (rec.mediaUrl && rec.capability === 'image') body = ``; - else if (rec.mediaUrl && rec.capability === 'video') body = ``; - else if (rec.mediaUrl && (rec.capability === 'tts' || rec.capability === 'music')) body = ``; - else body = `
Нет предпросмотра для этого типа.
`; + if (rec.mediaUrl && rec.capability === 'image') { + body = ``; + } else if (rec.mediaUrl && rec.capability === 'video') { + body = ``; + } else if (rec.mediaUrl && (rec.capability === 'tts' || rec.capability === 'music')) { + body = `
+
🎵
+ +
`; + } 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 = `
${esc(text)}
`; + } else { + body = `
Нет предпросмотра для этого типа.
`; + } const kv = `
Модель: ${esc(rec.model)}
diff --git a/NexusAI/packages/admin/public/index.html b/NexusAI/packages/admin/public/index.html index 7b35f0a..22e4d75 100644 --- a/NexusAI/packages/admin/public/index.html +++ b/NexusAI/packages/admin/public/index.html @@ -85,6 +85,26 @@ .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; } @@ -92,6 +112,14 @@

NexusAI Admin

+
diff --git a/NexusAI/packages/admin/src/server.ts b/NexusAI/packages/admin/src/server.ts index 43afb14..09f000c 100644 --- a/NexusAI/packages/admin/src/server.ts +++ b/NexusAI/packages/admin/src/server.ts @@ -27,6 +27,15 @@ async function main(): Promise { 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 = { + '.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); diff --git a/NexusAI/packages/core/src/index.ts b/NexusAI/packages/core/src/index.ts index 4a61391..7dd0262 100644 --- a/NexusAI/packages/core/src/index.ts +++ b/NexusAI/packages/core/src/index.ts @@ -11,5 +11,6 @@ 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'; diff --git a/NexusAI/packages/core/src/providers/model-catalog.ts b/NexusAI/packages/core/src/providers/model-catalog.ts new file mode 100644 index 0000000..312ff10 --- /dev/null +++ b/NexusAI/packages/core/src/providers/model-catalog.ts @@ -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; + 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 { + 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 { + await this.sync(); + return this.models; + } + + /** Models for a given capability. */ + async byCapability(capability: Capability): Promise { + 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 { + 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 + ); + } +} diff --git a/NexusAI/packages/mcp-server/src/context.ts b/NexusAI/packages/mcp-server/src/context.ts index b1ecb1a..898da82 100644 --- a/NexusAI/packages/mcp-server/src/context.ts +++ b/NexusAI/packages/mcp-server/src/context.ts @@ -3,6 +3,7 @@ import { KeyCipher, KeyResolver, MediaStorage, + ModelCatalog, ProviderRegistry, ProvidersStore, createNordRouterProviders, @@ -20,6 +21,8 @@ export interface AppContext { storage: MediaStorage; journal: Journal; providersStore: ProvidersStore; + /** Model catalogs keyed by provider name (for sync + fuzzy resolution). */ + catalogs: Map; /** Effective default provider name after resolving DB/env. */ defaultProvider: string; } @@ -47,6 +50,7 @@ export function buildContext(): AppContext { const resolved = resolver.resolveAll(BUILTINS); const defaultProvider = resolver.resolveDefault(resolved); + const catalogs = new Map(); for (const p of resolved) { if (!p.enabled) { @@ -69,6 +73,12 @@ export function buildContext(): AppContext { 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)); @@ -82,5 +92,5 @@ export function buildContext(): AppContext { log.info('registered local STT provider'); log.info(`default provider: ${defaultProvider}`); - return { config, registry, storage, journal, providersStore, defaultProvider }; + return { config, registry, storage, journal, providersStore, catalogs, defaultProvider }; } diff --git a/NexusAI/packages/mcp-server/src/handlers.ts b/NexusAI/packages/mcp-server/src/handlers.ts index 2bf3f39..c779d40 100644 --- a/NexusAI/packages/mcp-server/src/handlers.ts +++ b/NexusAI/packages/mcp-server/src/handlers.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'crypto'; import { + ModelResolutionError, NexusError, NordRouterClient, buildEmbeddedContent, @@ -8,6 +9,7 @@ import { type MediaResult, } from '@nexusai/core'; import * as fs from 'fs'; +import * as path from 'path'; import type { AppContext } from './context.js'; import { ImageSchema, @@ -89,13 +91,36 @@ export class Handlers { 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: args.model, input: args.input }); + const result = await provider.generate(capability, { model, input: args.input }); this.ctx.journal.record({ id, provider: providerName, capability, - model: args.model, + model, inputSummary: summarize(args.input), status: 'done', costUsd: result.costUsd ?? null, @@ -105,14 +130,14 @@ export class Handlers { durationMs: Date.now() - started, createdAt: new Date().toISOString(), }); - return this.mediaResult(result, args.output?.embed ?? false); + 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: args.model, + model, inputSummary: summarize(args.input), status: 'failed', 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[] = [ jsonBlock({ provider: result.provider, @@ -138,6 +163,7 @@ export class Handlers { result_url: result.resultUrl, result_url_2: result.resultUrl2, cost_usd: result.costUsd, + ...(note ? { note } : {}), }), ]; @@ -159,6 +185,9 @@ export class Handlers { 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, @@ -167,8 +196,7 @@ export class Handlers { inputSummary: summarize({ ...args.input, audioBase64: args.input.audioBase64 ? '[base64]' : undefined }), status: 'done', costUsd: 0, - // STT does not produce a local media file; the source audio path stays in inputSummary. - filePath: null, + filePath: transcriptPath, resultUrl: null, error: null, 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: { provider?: string; query: string;