35 Commits
Author SHA1 Message Date
naeel f55da318e5 fix: reasoning_content fallback, +Qwen модель, обе живы 2026-06-02 09:18:10 +03:00
naeel 71b8e414d8 fix: .gitignore node_modules 2026-06-02 09:11:02 +03:00
naeel f0f67909a0 fix: form-data пакет вместо Blob, правильный multipart 2026-06-02 09:10:52 +03:00
naeel 6c2f23539f fix: compact spacing - meter 32px, pad .5rem, less gaps 2026-06-02 06:45:19 +03:00
naeel d7ef0b02fd fix: компактный лейаут — модельки в хедер, меньше отступов 2026-06-02 06:23:09 +03:00
naeel abe149cf10 fix: лого — 5 path вместо 3, теперь nubes 2026-06-02 06:19:48 +03:00
naeel 0033f01683 v1.5 — день по умолчанию, resetState(), все комбинации кнопок 2026-06-02 06:17:30 +03:00
naeel 902b341f35 fix: пустая транскрибация — без кнопки отправки 2026-06-02 06:14:11 +03:00
naeel 6374b20ea8 v1.4 — день/ночь в Управление, кнопка подтверждения, комбо текст+микрофон 2026-06-02 06:09:45 +03:00
naeel 8138406ce3 v1.3 — лого встроено в HTML 2026-06-02 05:58:39 +03:00
naeel a7bf69a396 v1.2 — дизайн как Nubes ipwhitelist 2026-06-01 23:27:17 +03:00
naeel b55390acc3 v1.1 2026-06-01 23:23:50 +03:00
naeel 91c42a1d3e fix: дизайн как Личный Кабинет — карточки, светлый фон, как в lang 2026-06-01 23:20:16 +03:00
naeel 7f10b95650 feat: стиль Nubes — favicon, лого, цвета, шрифты как в ipwhitelist 2026-06-01 23:12:36 +03:00
naeel 3929c07e8e feat: кнопка день/ночь 2026-06-01 23:09:58 +03:00
naeel 55fb1f658b fix: память только в браузере, сервер ничего не хранит 2026-06-01 23:09:18 +03:00
naeel edbcde0dcc fix: модель Whisper рядом с GPT 2026-06-01 22:58:37 +03:00
naeel 553665c61e feat: форматирование — переносы строк, жирный, списки, цветные блоки 2026-06-01 22:56:11 +03:00
naeel 57954b3e65 feat: версия, таймер ответа, история 20 вопросов, боковая панель 2026-06-01 22:53:07 +03:00
naeel 62da6ce35c update rag plan with real volume estimates 2026-06-01 22:52:51 +03:00
naeel 7ef1295045 fix: дубликат const cData, кнопка Записать 2026-06-01 22:39:45 +03:00
naeel d9c5a578f4 feat: память сессии — контекст диалога, кнопка Сброс 2026-06-01 22:35:57 +03:00
naeel 5f0ca9e138 feat: русский UI, уровень микрофона, прослушивание, таймауты 2026-06-01 22:27:19 +03:00
naeel 8f62f88bb7 fix: whisper-large-v3-turbo 2026-06-01 22:19:59 +03:00
naeel 0a612bcc72 fix: только gpt-oss-120b — остальные модели 400 на aillm.ru 2026-06-01 22:18:19 +03:00
naeel 547d1a6118 fix: 4 модели — DDM-120, DDM-20, Qwen, DeepSeek 2026-06-01 22:16:11 +03:00
naeel 1945f8868c feat: Express + Whisper + 3 LLM + UI с микрофоном 2026-06-01 22:06:28 +03:00
naeel 098c697973 add elsawin analysis 2026-06-01 21:50:51 +03:00
naeel 0244f2f612 feat: Node.js — чистый http, /api/profiles, 0 зависимостей 2026-06-01 21:42:00 +03:00
naeel 9406b7d159 feat: /api/profiles — 3 LLM 2026-06-01 21:30:41 +03:00
naeel c055d46d15 fix: удалил мёртвые файлы config, routes, services 2026-06-01 21:20:14 +03:00
naeel 1b6e2d5519 fix: python:3.9-slim как у BG 2026-06-01 18:52:25 +03:00
naeel 05bc449035 fix: health endpoint /healthz (k8s convention) 2026-06-01 18:26:41 +03:00
naeel a5efb6acbc fix: структура как у BG — class-based, debug=True, Dockerfile, чистый requirements 2026-06-01 18:22:44 +03:00
naeel 6ff52e1b75 fix: app.py — один файл, без импортов, как у BG 2026-06-01 07:22:46 +03:00
23 changed files with 2295 additions and 1041 deletions
+1
View File
@@ -4,3 +4,4 @@ __pycache__/
venv/ venv/
.venv/ .venv/
*.egg-info/ *.egg-info/
node_modules/
+1
View File
@@ -0,0 +1 @@
disable=missing-function-docstring
+93
View File
@@ -0,0 +1,93 @@
# Анализ ElsaWin (`/mnt/y/MY/SOFT/ElsaWin/`)
Дата: 2026-06-01
Всего файлов: ~479,887
**ElsaWin** — дилерская программа для Volkswagen/Audi/Škoda/Seat (ремонт, электросхемы, диагностика).
---
## 📁 Структура папок
| Папка | Содержимое |
|---|---|
| `bin/` | Исполняемые файлы (.exe, .dll) — Windows-клиент |
| `client/` | Клиентская часть (Setup.exe, сертификаты) |
| `data/` | Базы MS Access (.mdb) — метаданные |
| `docs/` | **Словарные статьи**`au`, `help`, `hs2`, `igg`, `ki`, `rl`, `slp`, `www` |
| `graphics/` | Иллюстрации (SVG, PNG, JPG) |
| `html/` | HTML-ресурсы |
| `log/` | Логи |
| `mobile/` | Мобильная версия |
| `version/` | Версионные данные |
| `config.xml` | Главный конфиг |
---
## ✅ Что можно читать/показывать
### 📄 Текстовые форматы (читаю напрямую)
| Тип | Кол-во | Описание |
|---|---|---|
| **HTM / HTML** | ~57,627 | Справочные страницы |
| **XML** | 453 | Данные |
| **JS** | 266 | JavaScript |
| **CSS** | 204 | Стили |
| **XSL** | 179 | XSLT-шаблоны |
| **ENT** | 168 | SGML/XML entity-файлы |
| **VTT / SRT** | 85 | Субтитры |
| **DTD** | 36 | DTD-схемы |
| **PHP / PHPS** | 36 | PHP-скрипты |
| **JSON / TXT / INI / BAT / LOG** | ~20 | Разное |
### 🖼️ Графика (можно показывать через `view_image`)
| Тип | Кол-во | Статус |
|---|---|---|
| **SVG / SVGZ** | ~208,900 | Векторная графика — **можно показывать** |
| **PNG** | ~158,985 | Растровая — **можно показывать** |
| **JPG** | ~25,812 | Фото — **можно показывать** |
| **TIFF** | 17,184 | Можно конвертировать |
| **GIF** | ~1,795 | Анимация — **можно показывать** |
| **PN** | 34 | На самом деле PNG (переименованы) |
### 🗄️ WI-файлы (можно извлекать!)
| Тип | Кол-во | Статус |
|---|---|---|
| **WI** | ~4,260 | **✅ ПОЛНОСТЬЮ ЧИТАЕМЫ** |
**.wi — это OLE2 (Compound Document) контейнер**, внутри которого лежат обычные **XML-файлы**. Никакого шифрования/кодирования нет.
**Инструмент:** Python-библиотека `olefile` (установлена).
**Процесс:** `olefile.OleFileIO(file.wi)` → список XML-потоков → читаем как XML.
Пример содержимого XML из .wi:
```xml
<h-kap typen-bez="6V2,6V5,9K9" xml:lang="de-DE">
<vorspann druck-num="004564150.00" dok-id="L3.004564150.00">
<marke>VW</marke>
<typ><modell>Caddy Kasten/Kombi</modell></typ>
</vorspann>
<titel>Motorenübersicht</titel>
<absatz typ="normal">In diesem Kapitel finden Sie Informationen...</absatz>
</h-kap>
```
---
## ❌ Что НЕльзя прочитать (бинарные)
| Тип | Кол-во | Проблема |
|---|---|---|
| **MDB** | 272 | MS Access / Jet DB. Нужен `mdbtools` |
| **DB** | 508 | SQLite/Berkeley — можно попробовать через sqlite3 |
| **DLL** | 125 | Windows PE-библиотеки |
| **EXE** | 15 | Исполняемые Windows |
| **WMA / MPG / MP4 / AVI** | ~240 | Аудио/видео |
| **DOC / XLS** | 94 | MS Office |
| **PDF** | 2,524 | PDF-документы |
| **SCC** | 9 | Visual SourceSafe |
| **RAR / ZIP / SWF** | 6 | Архивы / Flash |
| **ICO / CUR / TTF** | 14 | Иконки / шрифты |
+133
View File
@@ -0,0 +1,133 @@
# ElsaWin → RAG: план
---
## 1. Источник данных
### .wi файлы (основной контент)
| Язык | Кол-во .wi | Размер на диске |
|---|---|---|
| **en-GB** | **1,716** | **706 MB** |
| de-DE | 1,588 | 688 MB |
| ru-RU | 956 | 588 MB |
Каждый `.wi` — OLE2-контейнер, внутри XML-файлы (от 1 до ~1500 на .wi).
### Графика
SVG/PNG/JPG — ~400K файлов. Технически можно привязать как multimodal, но сложно.
---
## 2. Пайплайн извлечения
```
.wi (OLE2) → olefile → XML-потоки → парсинг SGML → чистый текст → чанки → векторная БД
```
### Этапы:
1. **Извлечение**: `olefile` — обкатано, работает
2. **Парсинг**: SGML → text (кастомный парсер на Python, т.к. разметка своя: `<h-kap>`, `<titel>`, `<tabelle>`, `<aufzaehlg>`, etc.)
3. **Чистка**: удалить мусор (якоря `<qv-anker>`, `<g>`-теги, служебные атрибуты)
4. **Чанкинг**:
- По `<titel>` (заголовкам) — иерархические чанки
- Размер: ~500-1000 токенов
- Перекрытие: ~100 токенов
5. **Embedding**: `text-embedding-3-small` (OpenAI) или `bge-m3` (локально)
6. **Векторная БД**: ChromaDB (проще всего) или Qdrant (для прода)
---
## 3. Объёмы (en-GB, реальные замеры)
| Этап | Размер |
|---|---|
| **Сырые .wi файлы** (на диске) | **~706 MB** |
| XML-потоки внутри (распаковано) | ~10-15 GB |
| Чистый текст после парсинга | ~3-5 GB |
| Чанки (текст) | ~2-3 GB |
| **Векторная БД (ChromaDB)** | **~10-15 GB** |
| **Итого на диске** | **~15-20 GB** |
### Расходы
| Статья | Бесплатно (локально) | Через API |
|---|---|---|
| Embedding | **$0** (bge-m3 на 3060) | ~$30 (OpenAI) |
| LLM для ответов | **$0** (Ollama) | ~$7 / 10K запросов |
| Векторная БД | **$0** (ChromaDB) | — |
| **Итого** | **$0** (+ электричество) | ~$30 + операционка |
### Время на RTX 3060 12GB
| Этап | Время |
|---|---|
| Парсинг + чанкинг (1.5 млн XML) | ~1-2 часа |
| Embedding (bge-m3, 2 млн чанков) | ~3-5 часов |
| Запись в БД | ~30 мин |
| **Итого en-GB** | **~5-7 часов** |
---
## 4. Архитектура RAG
```
User → Query → Embedding → Поиск по векторной БД → Топ-K чанков
→ LLM (с контекстом) → Ответ
```
### Варианты LLM:
| Вариант | Плюсы | Минусы |
|---|---|---|
| **OpenAI GPT-4o** | Качество, скорость | Дорого, нужен API-ключ |
| **Ollama (локально)** | Бесплатно, privacy | Нужна GPU, медленнее |
| **Claude API** | Большой контекст | Дорого |
### Стратегия поиска:
- **Гибридный**: векторный + keyword (BM25) — лучше для технических терминов
- **reranking** по `cross-encoder` для топ-50 результатов
---
## 5. Варианты реализации
### A) Minimal Viable
- Только en-GB
- ChromaDB на диске
- Ollama + llama3 локально
- CLI или простой веб-интерфейс (Streamlit/Gradio)
### B) Полноценный
- en-GB + ru-RU (билингва)
- Qdrant с гибридным поиском
- FastAPI бэкенд + React фронтенд
- Автообновление при изменении данных
### C) Multimodal
- Всё из B
- + привязка SVG/PNG иллюстраций к чанкам
- Поддержка вопросов с картинками
---
## 6. Риски
| Риск | Решение |
|---|---|
| SGML-парсинг сложнее чем кажется | Написать итеративно, тестировать на разных .wi |
| Объём данных больше оценки | Сначала пилот на одном разделе (igg/) |
| Качество Russian перевода низкое | Использовать en-GB как primary, ru-RU как fallback |
| Производительность поиска на больших данных | Индексация + гибридный поиск |
| Юридические ограничения (VW/Audi data) | Использовать только локально |
---
## 7. Первый шаг (пилот)
1. Взять **один .wi файл** (`rl/N.en-GB.000528929.wi`)
2. Извлечь XML → спарсить → зачанковать
3. Залить в ChromaDB
4. Проверить качество ответов через Ollama
5. Если ОК — масштабировать на все en-GB файлы
**Оценка времени пилота:** ~2-4 часа на извлечение + парсинг + тест.
**Полный пайплайн (en-GB):** ~1-3 дня.
+981
View File
@@ -0,0 +1,981 @@
{
"name": "say",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "say",
"version": "1.0.0",
"dependencies": {
"express": "^4.21.0",
"form-data": "^4.0.5",
"multer": "^1.4.5-lts.1"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"engines": [
"node >= 0.8"
],
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"engines": {
"node": ">=0.4"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "say",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.21.0",
"form-data": "^4.0.5",
"multer": "^1.4.5-lts.1"
}
}

Before

Width:  |  Height:  |  Size: 1017 B

After

Width:  |  Height:  |  Size: 1017 B

+352
View File
@@ -0,0 +1,352 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Say — Голосовой ассистент | Nubes</title>
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.85rem;color:var(--muted);">
<svg viewBox="0 0 311 69" width="130" height="28" style="display:block;">
<g transform="translate(-330 -228)">
<path d="M348.061 246.203 332.42 246.203 330 256.661 336.75 256.661 336.794 295.573 336.794 296.585 336.795 296.585 347.25 296.585 347.25 263.611C347.25 259.78 350.368 256.663 354.2 256.663L376.489 256.793C380.322 256.793 383.44 259.912 383.44 263.743L383.44 296.717 393.896 296.717 393.896 263.743C393.896 254.144 386.087 246.338 376.495 246.338L354.196 246.204 348.061 246.221 348.061 246.203Z" fill="#2563eb" fill-rule="evenodd"/>
<path d="M582.095 296.65 626.768 296.65C634.793 296.65 641 290.03 641 282.009 641 273.985 634.473 267.458 626.449 267.458L601.724 267.458C599.446 267.458 597.595 265.607 597.595 263.328L597.595 260.825C597.595 258.548 599.446 256.697 601.724 256.697L628.041 256.697 630.452 246.276 601.724 246.276C593.7 246.276 587.174 252.802 587.174 260.825L587.174 263.328C587.174 271.352 593.7 277.879 601.724 277.879L626.449 277.879C628.726 277.879 630.579 279.732 630.579 282.009 630.579 284.283 628.726 286.4 626.449 286.4L584.126 286.4 582.095 296.65Z" fill="#2563eb" fill-rule="evenodd"/>
<path d="M564.549 246.347 537.193 246.347C529.027 246.347 522.385 252.989 522.385 261.156L522.385 279.521C522.385 288.916 530.028 296.557 539.422 296.557L576.846 296.557 579.196 286.401 539.422 286.401C535.775 286.401 532.805 283.172 532.805 279.521L532.748 278.028 567.684 278.028 579.354 277.959 579.354 268.958 579.354 261.156C579.354 252.989 572.712 246.347 564.549 246.347ZM568.935 267.54 532.805 267.54 532.805 261.156C532.805 258.737 534.774 256.767 537.193 256.767L564.549 256.767C566.966 256.767 568.935 258.737 568.935 261.156L568.935 267.54Z" fill="#2563eb" fill-rule="evenodd"/>
<path d="M507.156 273.231 507.156 279.597C507.156 283.414 504.066 286.344 500.247 286.344L477.934 286.39C474.41 286.39 471.493 283.744 471.064 280.335L471.064 263.006C471.494 259.597 474.41 256.951 477.934 256.951L500.23 257.083C504.049 257.083 507.156 260.192 507.156 264.009L507.156 265.16 507.156 273.232ZM460.623 260.161 460.589 230.089 471.064 228.058 471.064 247.957C473.172 247.043 475.49 246.53 477.929 246.53L500.235 246.662C509.793 246.662 517.575 254.444 517.575 264.009L517.575 265.159 517.575 273.231 517.575 279.597C517.575 289.161 509.793 296.941 500.235 296.941L477.929 296.809C468.37 296.809 460.589 289.029 460.589 279.464L460.589 272.972 460.589 263.876 460.623 260.161Z" fill="#2563eb" fill-rule="evenodd"/>
<path d="M445.037 271.59 445.037 279.317C445.037 283.134 441.93 286.241 438.111 286.241L416.092 286.11C412.273 286.11 409.166 283.002 409.166 279.185L409.166 276.635 409.222 276.644 409.222 244.013 398.748 246.044 398.752 249.955 398.748 249.957 398.748 279.184C398.748 288.749 406.529 296.529 416.087 296.529L438.116 296.661C447.674 296.661 455.456 288.881 455.456 279.316L455.456 276.639 455.482 276.643 455.482 244.013 445.008 246.043 445.037 271.589Z" fill="#2563eb" fill-rule="evenodd"/>
</g>
</svg>
<span style="color:var(--border);">|</span>
<b style="color:var(--text);">Say</b>
<span id="ver" style="margin-left:4px;"></span>
<span style="margin-left:auto;">Голосовой ассистент</span>
</header>
<div class="page">
<div class="main">
<div class="card">
<div class="card-header" style="display:flex;align-items:center;gap:10px;">
<span>Управление</span>
<span style="display:flex;gap:6px;margin:0 auto;">
<span class="tag">🤖 GPT-OSS-120B</span>
<span class="tag">🤖 Qwen3.6-27B</span>
<span class="tag">🎤 Whisper-v3-Turbo</span>
</span>
<span style="cursor:pointer;font-size:16px;" id="btnTheme">🌙</span>
</div>
<div class="card-body" style="padding:.5rem .75rem;">
<canvas id="meter" width="400" height="32" style="width:100%;border-radius:4px;background:var(--grey-light);margin-bottom:4px;"></canvas>
<div style="display:flex;gap:.4rem;align-items:center;">
<button id="btnRecord" class="btn btn-primary" style="padding:.4rem .8rem;">🎙 Записать</button>
<button id="btnPlay" class="btn" disabled style="padding:.4rem .8rem;">▶ Прослушать</button>
<button id="btnReset" class="btn" style="padding:.4rem .8rem;">🗑 Сброс</button>
</div>
<div id="status" class="status" style="margin-top:2px;font-size:.8rem;"></div>
</div>
</div>
<div class="card">
<div class="card-header">Диалог</div>
<div class="card-body" style="padding:.5rem .75rem;">
<div style="display:flex;gap:.4rem;margin-bottom:6px;">
<input id="txtInput" type="text" placeholder="Текст + микрофон — всё в одном запросе" style="flex:1;padding:.4rem .6rem;border:1px solid var(--border);border-radius:5px;font-size:.85rem;outline:none;background:var(--card);color:var(--text);">
<button id="btnSend" class="btn" style="padding:.4rem .7rem;font-size:12px;"></button>
</div>
<div id="chat" style="min-height:160px;max-height:300px;overflow-y:auto;"></div>
</div>
</div>
</div>
<div class="side">
<div class="card">
<div class="card-header">История</div>
<div class="card-body" id="history-list"></div>
</div>
</div>
</div>
<script>
const VERSION = 'v1.5';
document.getElementById('ver').textContent = VERSION;
// DOM-элементы
const btnRecord = document.getElementById('btnRecord');
const btnPlay = document.getElementById('btnPlay');
const statusEl = document.getElementById('status');
const chat = document.getElementById('chat');
const histList = document.getElementById('history-list');
const meter = document.getElementById('meter');
const meterCtx = meter.getContext('2d');
const txtInput = document.getElementById('txtInput');
const btnSend = document.getElementById('btnSend');
const btnTheme = document.getElementById('btnTheme');
// Состояние
let mediaRecorder = null, chunks = [], userBlob = null;
let isRecording = false, micAnalyser = null, micAnimId = null;
let audioContext = null, chatHistory = [], stream = null;
let pendingText = null, pendingSendBtn = null;
let abortTranscribe = null, abortLLM = null, transcribeTimer = null, llmTimer = null;
// Только день! Никакой ночи по умолчанию
localStorage.setItem('say_dark', '0');
document.body.classList.remove('dark');
btnTheme.textContent = '🌙';
let history = JSON.parse(localStorage.getItem('say_history') || '[]');
function setStatus(msg) { statusEl.innerHTML = msg; }
// ===== Сброс состояния =====
function resetState(keepHistory) {
// Останавливаем запись если идёт
if (mediaRecorder && mediaRecorder.state === 'recording') {
try { mediaRecorder.stop(); } catch(e) {}
}
// Закрываем стрим микрофона
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
// Отменяем анимацию
cancelAnimationFrame(micAnimId);
if (audioContext) try { audioContext.close(); } catch(e) {}
// Отменяем запросы
if (abortTranscribe) try { abortTranscribe.abort(); } catch(e) {}
if (abortLLM) try { abortLLM.abort(); } catch(e) {}
// Таймеры
clearInterval(transcribeTimer); clearInterval(llmTimer);
// Состояния
audioContext = null; micAnalyser = null; micAnimId = null;
isRecording = false; userBlob = null;
pendingText = null;
if (pendingSendBtn) { pendingSendBtn.remove(); pendingSendBtn = null; }
// Кнопки
btnRecord.textContent = '🎙 Записать'; btnRecord.disabled = false;
btnPlay.disabled = true;
if (!keepHistory) { chatHistory = []; chat.innerHTML = ''; }
}
// ===== История =====
function renderHistory() {
histList.innerHTML = history.slice(-20).map((h, i) =>
'<div class="hitem" data-idx="' + i + '" title="' + h.time + '">' +
'<span class="hq">' + esc(h.q).substring(0, 60) + '</span>' +
'</div>'
).join('');
document.querySelectorAll('.hitem').forEach(el => {
el.onclick = () => {
const h = history[+el.dataset.idx];
chat.innerHTML += '<div class="msg user">📋 ' + fmt(h.q) + '</div>';
chat.innerHTML += '<div class="msg bot">🤖 ' + fmt(h.a) + '</div>';
chat.scrollTop = chat.scrollHeight;
};
});
}
function esc(s) { return String(s).replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmt(s) {
s = esc(s);
s = s.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
s = s.replace(/`(.+?)`/g, '<code>$1</code>');
s = s.replace(/\*(.+?)\*/g, '<i>$1</i>');
s = s.replace(/^---+/gm, '<hr>');
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
s = s.replace(/^# (.+)$/gm, '<h1>$1</h1>');
s = s.replace(/^[•\-*] (.+)$/gm, '<li>$1</li>');
s = s.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>');
s = s.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>');
s = s.replace(/\n\n+/g, '</p><p>');
s = s.replace(/\n/g, '<br>');
s = s.replace(/<ul>\s*<\/ul>/g, '');
return '<p>' + s + '</p>';
}
renderHistory();
// ===== Сброс (работает в любом состоянии) =====
document.getElementById('btnReset').onclick = () => {
resetState(false);
setStatus('🧹 Сброшено');
};
// ===== Отправка текста =====
async function doSend() {
if (btnRecord.disabled) return; // если идёт процесс — игнорируем
let text = '';
if (txtInput.value.trim()) text += txtInput.value.trim() + ' ';
if (pendingText) text += pendingText;
if (!text.trim()) return;
txtInput.value = '';
if (pendingSendBtn) { pendingSendBtn.remove(); pendingSendBtn = null; pendingText = null; }
chat.innerHTML += '<div class="msg user">🗣 ' + fmt(text.trim()) + '</div>';
chat.scrollTop = chat.scrollHeight;
await askLLM(text.trim());
}
btnSend.onclick = doSend;
txtInput.onkeydown = e => { if (e.key === 'Enter' && !btnRecord.disabled) doSend(); };
// ===== Запись =====
btnRecord.onclick = async () => {
if (isRecording) {
// Остановить запись
isRecording = false;
btnRecord.disabled = true;
btnRecord.textContent = '⏳ Обработка...';
setStatus('⏳ Обработка...');
mediaRecorder.stop();
return;
}
// Начать запись
try {
resetState(true); // сброс без очистки чата
isRecording = true; btnRecord.textContent = '⏹ Стоп'; btnRecord.disabled = false;
setStatus('🎤 Запись...');
chunks = [];
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);
micAnalyser = audioContext.createAnalyser(); micAnalyser.fftSize = 256;
source.connect(micAnalyser); drawMeter();
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => chunks.push(e.data);
mediaRecorder.onstop = async () => {
cancelAnimationFrame(micAnimId);
if (audioContext) try { audioContext.close(); } catch(e) {}
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
audioContext = null; micAnalyser = null; micAnimId = null;
if (chunks.length === 0) { resetState(true); setStatus('❗ Запись пуста'); return; }
userBlob = new Blob(chunks, { type: 'audio/webm' });
btnPlay.disabled = false;
await transcribe();
};
mediaRecorder.start();
} catch (err) {
resetState(true);
let msg = '⛔ ';
if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён.';
else if (err.name === 'NotFoundError') msg += 'Микрофон не найден.';
else msg += 'Ошибка: ' + (err.message || err.name);
setStatus(msg);
}
};
function drawMeter() {
if (!micAnalyser) return;
const data = new Uint8Array(micAnalyser.frequencyBinCount);
micAnalyser.getByteTimeDomainData(data);
const w = meter.width, h = meter.height;
meterCtx.fillStyle = '#1a1a2e';
meterCtx.fillRect(0, 0, w, h);
meterCtx.strokeStyle = '#6c5ce7';
meterCtx.lineWidth = 2; meterCtx.beginPath();
for (let i = 0; i < data.length; i++)
meterCtx.lineTo((i / data.length) * w, (data[i] / 255) * h);
meterCtx.stroke();
micAnimId = requestAnimationFrame(drawMeter);
}
btnPlay.onclick = () => {
if (!userBlob) return;
const a = new Audio(URL.createObjectURL(userBlob));
a.onended = () => URL.revokeObjectURL(a.src);
a.play(); setStatus('🔊 Ваша запись...');
};
// ===== Транскрибация =====
async function transcribe() {
const tStart = Date.now();
setStatus('🎤 Транскрибация... 0с');
transcribeTimer = setInterval(() => setStatus('🎤 Транскрибация... ' + ((Date.now() - tStart) / 1000).toFixed(0) + 'с'), 200);
abortTranscribe = new AbortController();
try {
const fd = new FormData(); fd.append('audio', userBlob);
setTimeout(() => abortTranscribe.abort(), 30000);
const tRes = await fetch('/api/whisper', { method: 'POST', body: fd, signal: abortTranscribe.signal });
clearInterval(transcribeTimer);
const tData = await tRes.json();
const text = tData.text || '';
const tElapsed = ((Date.now() - tStart) / 1000).toFixed(1);
if (!text.trim()) {
setStatus('⛔ Ничего не распознано (' + tElapsed + 'с)');
btnRecord.textContent = '🎙 Записать'; btnRecord.disabled = false; isRecording = false;
userBlob = null; btnPlay.disabled = true;
return;
}
setStatus('✅ Транскрибация: ' + tElapsed + 'с');
const msgDiv = document.createElement('div');
msgDiv.className = 'msg user';
msgDiv.innerHTML = '🗣 ' + fmt(text);
const btn = document.createElement('button');
btn.className = 'btn btn-primary';
btn.textContent = '✓ Отправить';
btn.style.marginTop = '8px'; btn.style.fontSize = '.8rem';
btn.onclick = () => {
pendingText = text;
doSend();
};
msgDiv.appendChild(btn);
chat.appendChild(msgDiv);
chat.scrollTop = chat.scrollHeight;
} catch (err) {
if (err.name === 'AbortError') setStatus('⛔ Таймаут транскрибации');
else setStatus('⛔ Ошибка: ' + err.message);
clearInterval(transcribeTimer);
}
btnRecord.textContent = '🎙 Записать'; btnRecord.disabled = false; isRecording = false;
}
// ===== LLM =====
async function askLLM(text) {
const lStart = Date.now();
btnRecord.disabled = true; btnSend.disabled = true;
statusEl.innerHTML = statusEl.innerHTML.replace(/⏳.*/, '') + '<br>🤔 Думаю... 0с';
llmTimer = setInterval(() => {
const sec = ((Date.now() - lStart) / 1000).toFixed(0);
statusEl.innerHTML = statusEl.innerHTML.replace(/🤔 Думаю... [\d.]+с/, '🤔 Думаю... ' + sec + 'с');
}, 200);
abortLLM = new AbortController();
try {
setTimeout(() => abortLLM.abort(), 120000);
const cRes = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'gpt-oss-120b', message: text, history: chatHistory }),
signal: abortLLM.signal,
});
clearInterval(llmTimer);
const cData = await cRes.json();
const resp = cData.response || cData.error || 'Ошибка';
const lElapsed = ((Date.now() - lStart) / 1000).toFixed(1);
statusEl.innerHTML = statusEl.innerHTML.replace(/🤔 Думаю... [\d.]+с/, '✅ Ответ: ' + lElapsed + 'с');
chatHistory.push({ role: 'user', content: text });
chatHistory.push({ role: 'assistant', content: resp });
if (chatHistory.length > 40) chatHistory = chatHistory.slice(-40);
chat.innerHTML += '<div class="msg bot">🤖 ' + fmt(resp) + '</div>';
chat.scrollTop = chat.scrollHeight;
history.push({ q: text, a: resp, time: new Date().toLocaleTimeString() });
if (history.length > 20) history.shift();
localStorage.setItem('say_history', JSON.stringify(history));
renderHistory();
} catch (err) {
clearInterval(llmTimer);
if (err.name === 'AbortError') statusEl.innerHTML = statusEl.innerHTML.replace(/🤔 Думаю... [\d.]+с/, '⛔ Таймаут');
else statusEl.innerHTML = statusEl.innerHTML.replace(/🤔 Думаю... [\d.]+с/, '⛔ ' + err.message);
}
btnRecord.disabled = false; btnSend.disabled = false;
userBlob = null; btnPlay.disabled = true;
}
// ===== Тема =====
btnTheme.onclick = () => {
const dark = document.body.classList.toggle('dark');
btnTheme.textContent = dark ? '☀️' : '🌙';
localStorage.setItem('say_dark', dark ? '1' : '0');
};
</script>
</body>
</html>
+543
View File
@@ -0,0 +1,543 @@
<!DOCTYPE html>
<html lang="ru" dir="ltr" prefix="og: https://ogp.me/ns#">
<head>
<title>Страница не найдена | Nubes</title>
<meta charset="utf-8" />
<link rel="canonical" href="http://nubes.ru/" />
<link rel="shortlink" href="http://nubes.ru/" />
<meta name="robots" content="index, follow" />
<meta property="og:site_name" content="Nubes" />
<meta property="og:type" content="website" />
<meta property="og:url" content="http://nubes.ru/favicon.svg" />
<meta property="og:title" content="Страница не найдена" />
<meta name="Generator" content="Drupal 10 (https://www.drupal.org)" />
<meta name="MobileOptimized" content="width" />
<meta name="HandheldFriendly" content="true" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/themes/custom/nubes_2025/favicon.png" type="image/png" />
<link rel="stylesheet" media="all" href="/files/css/css_jWcmDtZ1obzejBSRGuMNqJBGUcExB89Lf86WbU4U_vw.css?delta=0&amp;language=ru&amp;theme=nubes_2025&amp;include=eJxlTgsKwjAMvVBtZeB5RrJlpS5tRpPh5umtOFEYhCTvkw_CMPcmLZaAv75PgxSHIqZWYbk1TekfZ1KFSOoGYakoWxhpgpWtEZVCkZqB05McbcapzGGs6wLsD-jKiqR9d-1uIbIg8EVtb0p0uqtR_hx8EE5tUziqhztsJ5KYMhXzIxkkVm8SI59nv7bj85P-Ti8_h2g3" />
<link rel="stylesheet" media="all" href="/files/css/css_vCUKkxYRRNAwlY6wZwLrhAcLThWHxfJGtyLGWsxWz9A.css?delta=1&amp;language=ru&amp;theme=nubes_2025&amp;include=eJxlTgsKwjAMvVBtZeB5RrJlpS5tRpPh5umtOFEYhCTvkw_CMPcmLZaAv75PgxSHIqZWYbk1TekfZ1KFSOoGYakoWxhpgpWtEZVCkZqB05McbcapzGGs6wLsD-jKiqR9d-1uIbIg8EVtb0p0uqtR_hx8EE5tUziqhztsJ5KYMhXzIxkkVm8SI59nv7bj85P-Ti8_h2g3" />
<link rel="icon" href="/themes/custom/nubes/images/nubes-ico.svg" type="image/svg+xml" />
<meta name="yandex-verification" content="57b2f86a0f06d0db" />
</head>
<body class="page-route-system-404 path-faviconsvg">
<a href="#main-content" class="visually-hidden focusable skip-link">
Перейти к основному содержанию
</a>
<div class="body-wrapper">
<div class="dialog-off-canvas-main-canvas d-flex flex-column h-100" data-off-canvas-main-canvas>
<div class="header-wrapper">
<header role="banner">
<nav class="navbar-expand-xl">
<div class="header d-flex">
<div class="header-mobile d-flex">
<div class="region region-nav-branding">
<div id="block-nubes-2025-brending" class="block block-system block-system-branding-block">
<div class="navbar-brand d-flex align-items-center">
<a href="/" title="Главная" rel="home" class="site-logo d-block">
<img src="/themes/custom/nubes_2025/logo.svg" alt="Главная" fetchpriority="high" />
</a>
<div>
</div>
</div>
</div>
</div>
<button class="navbar-toggler collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
</div>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<div class="region region-nav-main">
<nav role="navigation" aria-labelledby="block-nubes-2025-menu-menu" id="block-nubes-2025-menu" class="block block-menu navigation menu--main-rus">
<div class="visually-hidden h5" id="block-nubes-2025-menu-menu">Главное меню (Рус)</div>
<ul data-block="nav_main" class="menu">
<li class="menu-item menu-item--expanded link-service"
>
<span item="link-service">Сервисы</span>
<ul class="menu">
<li class="menu-item menu-item--collapsed"
>
<a href="/cloud/resources" data-drupal-link-system-path="node/300">Облачные ресурсы</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/ai" data-drupal-link-system-path="node/1067">Искусственный интеллект</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/cloud/storage" data-drupal-link-system-path="node/308">Хранение данных</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/cloud/workspace" data-drupal-link-system-path="node/309">Работа офиса</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/cloud/backup" data-drupal-link-system-path="node/310">Резервное копирование и DR</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/security" data-drupal-link-system-path="node/950">ИБ-сервисы</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/dc" data-drupal-link-system-path="node/974">Дата-центр</a>
</li>
</ul>
</li>
<li class="menu-item menu-item--expanded"
>
<span>Пользователям</span>
<ul class="menu">
<li class="menu-item menu-item--collapsed"
>
<a href="/instructions" data-drupal-link-system-path="instructions">Инструкции</a>
</li>
</ul>
</li>
<li class="menu-item"
>
<a href="/partners" data-drupal-link-system-path="node/976">Партнерам</a>
</li>
<li class="menu-item menu-item--expanded"
>
<span>О нас</span>
<ul class="menu">
<li class="menu-item"
>
<a href="/company/about" data-drupal-link-system-path="node/135">О компании</a>
</li>
<li class="menu-item"
>
<a href="/company/team" data-drupal-link-system-path="node/1045">О команде</a>
</li>
<li class="menu-item"
>
<a href="/company/licenses" data-drupal-link-system-path="node/333">Лицензии и сертификаты</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/blog" data-drupal-link-system-path="blog/all">Блог</a>
</li>
<li class="menu-item footer-d-none"
>
<a href="/contacts" item="footer-d-none" data-drupal-link-system-path="node/763">Контакты</a>
</li>
</ul>
</li>
</ul>
</nav>
</div>
<div class="region region-nav-additional">
<div id="block-nubes-2025-header-order" class="block block-block-content block-block-contentadce38a4-982f-4219-bb40-3ca331878f9c">
<div class="clearfix text-formatted field field--name-body field--type-text-with-summary field--label-hidden field__item"><div class="header-button">
<a class="button btn-phone phone" href="tel:+74957894135" onclick="ym(68913385,'reachGoal','header_phone_click'); return true;" data-bs-toggle="tooltip" data-bs-placement="bottom" title="+7 (495) 789-4135"><span>Связаться с нами</span></a>
<a class="button btn-login" href="https://deck.ngcloud.ru" target="_blank" onclick="ym(68913385,'reachGoal','header_login_click'); return true;" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Личный кабинет"><span>Регистрация / Вход</span></a>
</div></div>
</div>
<div id="block-nubes-2025-login" class="block block-block-content block-block-content81db9f5f-8007-473b-bf0b-1c9b0a80ecc0">
<div class="clearfix text-formatted field field--name-body field--type-text-with-summary field--label-hidden field__item"><div class="header-button d-none"><a class="button btn-login" onclick="return true;"><span>Регистрация&nbsp;/&nbsp;Вход</span></a></div></div>
</div>
</div>
</div>
</div>
</nav>
</header>
</div>
<main role="main">
<a id="main-content" tabindex="-1"></a>
<div class="content-header-wrapper">
<div class="content-header">
<div class="region region-content-header">
<div data-drupal-messages-fallback class="hidden"></div>
<div id="block-nubes-2025-strokanavigacii" class="block block-system block-system-breadcrumb-block">
<nav aria-label="breadcrumb">
<div id="system-breadcrumb" class="title h2 visually-hidden">Строка навигации</div>
<ol class="breadcrumb" itemscope itemtype="http://schema.org/BreadcrumbList">
<li class="breadcrumb-item" itemprop="itemListElement" itemscope itemtype="http://schema.org/ListItem">
<a href="/" itemprop="item"><span itemprop="name">Главная</span></a><meta itemprop="position" content="1" />
</li>
</ol>
</nav>
</div>
<div id="block-nubes-2025-page-title" class="block block-core block-page-title-block">
<h1>Страница не найдена</h1>
</div>
</div>
</div>
</div>
<div class="main-wrapper">
<div class="row">
<div class="order-1 order-lg-2 col-12">
<div class="content-wrapper">
<div class="region region-content">
<div id="block-nubes-2025-content" class="block block-system block-system-main-block">
Запрашиваемая страница не найдена.
</div>
</div>
</div>
</div>
</div>
<div class="content-footer-wrapper">
<div class="content-footer">
<div class="region region-content-footer">
<div id="block-nubes-2025-formsubscribe" class="block block-block-content block-block-content3bffcd9d-439b-405d-857a-484f93f5ef25">
<div class="clearfix text-formatted field field--name-body field--type-text-with-summary field--label-hidden field__item"><div class="front-form subscribe-form">
<div class="row">
<div class="col-12 col-xxl-4">
<div class="form-title">Будьте в&nbsp;курсе наших событий</div>
</div>
<div class="col-12 col-xxl-8">
<span id="webform-submission-subscribe-form-ajax-content"></span><div id="webform-submission-subscribe-form-ajax" class="webform-ajax-form-wrapper" data-effect="none" data-progress-type="fullscreen"><form class="webform-submission-form webform-submission-add-form webform-submission-subscribe-form webform-submission-subscribe-add-form js-webform-submit-once js-webform-details-toggle webform-details-toggle" data-drupal-selector="webform-submission-subscribe-add-form" action="/favicon.svg" method="post" id="webform-submission-subscribe-add-form" accept-charset="UTF-8">
<div class="row js-form-wrapper form-wrapper" data-drupal-selector="edit-row" id="edit-row"><div class="col-12 left-block js-form-wrapper form-wrapper" data-drupal-selector="edit-left-block" id="edit-left-block">
<div class="js-form-item form-item js-form-type-email form-type-email js-form-item-email form-item-email form-no-label">
<label for="edit-email" class="visually-hidden js-form-required form-required">Email</label>
<input data-drupal-selector="edit-email" type="email" id="edit-email" name="email" value="" size="60" maxlength="254" placeholder="name@yourcompany.com" class="form-email required form-control" required="required" aria-required="true" />
</div>
</div>
<div class="col-12 right-block js-form-wrapper form-wrapper" data-drupal-selector="edit-right-block" id="edit-right-block"><div data-drupal-selector="edit-button-block" id="edit-button-block" class="js-form-wrapper form-wrapper"><div data-drupal-selector="edit-actions" class="form-actions webform-actions js-form-wrapper form-wrapper" id="edit-actions">
<input class="webform-button--submit button-submit button button--primary js-form-submit form-submit btn btn-primary" data-drupal-selector="edit-actions-submit" data-disable-refocus="true" type="submit" id="edit-actions-submit" name="op" value="Подписаться" />
</div>
<div id="edit-politic" class="js-form-item form-item js-form-type-webform-markup form-type-webform-markup js-form-item-politic form-item-politic form-no-label">
<div class="text">Оставляя заявку, вы&nbsp;даёте согласие на&nbsp;обработку персональных данных и&nbsp;соглашаетесь с&nbsp;<a href="/privacy">политикой конфиденциальности</a></div>
</div>
</div>
</div>
</div>
<input autocomplete="off" data-drupal-selector="form-nhen6twbpxygx-wrujubwc2d1ijznzr7jxswhlgrsjo" type="hidden" name="form_build_id" value="form-NHeN6tWbpXygX_Wrujubwc2d1iJZnZr7jxSWHLgrsjo" />
<input data-drupal-selector="edit-webform-submission-subscribe-add-form" type="hidden" name="form_id" value="webform_submission_subscribe_add_form" />
<input data-drupal-selector="edit-honeypot-time" type="hidden" name="honeypot_time" value="9bZdK62OjSCXBM2poZI0RTdAU4f51tkqWUp5p0X-KXI" />
<div class="url-textfield js-form-wrapper form-wrapper" style="display: none !important;">
<div class="js-form-item form-item js-form-type-textfield form-type-textfield js-form-item-url form-item-url">
<label for="edit-url">Оставьте это поле пустым</label>
<input autocomplete="off" data-drupal-selector="edit-url" type="text" id="edit-url" name="url" value="" size="20" maxlength="128" class="form-text form-control" />
</div>
</div>
</form>
</div>
</div>
</div>
</div></div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer>
<div class="footer-wrapper">
<div class="region region-footer">
<div id="block-nubes-2025-cookie" class="block block-block-content block-block-contentbf796e1f-8995-4fcf-bd28-e62aa78f0972">
<div class="clearfix text-formatted field field--name-body field--type-text-with-summary field--label-hidden field__item"><div id="cookie-consent-block" style="display: none;">
<div class="container">
<div class="wrapper">
<div id="cookie-consent-text">
<span class="title">Продолжая использовать наш сайт, вы соглашаетесь с <a href="/privacy">политикой использования Cookies</a>. </span>
</div>
<div id="button-wrapper">
<a class="button" id="cookie-consent-button">Согласен</a>
</div>
</div>
<div id="close-button-wrapper">
<button type="button" class="btn-round" id="cookie-consent-close-button"></button>
</div>
</div>
</div></div>
</div>
<div id="block-nubes-2025-footer" class="footer-content block block-block-content block-block-content632f6451-0c10-4825-936c-787ccd945b6e">
<div class="clearfix text-formatted field field--name-body field--type-text-with-summary field--label-hidden field__item"><div class="row">
<div class="col-12 col-lg-6 order-lg-2 col-xxl-4 order-xxl-2 service">
<ul class="menu">
<li class="menu-item menu-item--expanded link-service"
>
<span item="link-service">Сервисы</span>
<ul class="menu">
<li class="menu-item menu-item--collapsed"
>
<a href="/cloud/resources" data-drupal-link-system-path="node/300">Облачные ресурсы</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/ai" data-drupal-link-system-path="node/1067">Искусственный интеллект</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/cloud/storage" data-drupal-link-system-path="node/308">Хранение данных</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/cloud/workspace" data-drupal-link-system-path="node/309">Работа офиса</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/cloud/backup" data-drupal-link-system-path="node/310">Резервное копирование и DR</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/security" data-drupal-link-system-path="node/950">ИБ-сервисы</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/dc" data-drupal-link-system-path="node/974">Дата-центр</a>
</li>
</ul>
</li>
<li class="menu-item menu-item--expanded"
>
<span>Пользователям</span>
<ul class="menu">
<li class="menu-item menu-item--collapsed"
>
<a href="/instructions" data-drupal-link-system-path="instructions">Инструкции</a>
</li>
</ul>
</li>
<li class="menu-item"
>
<a href="/partners" data-drupal-link-system-path="node/976">Партнерам</a>
</li>
<li class="menu-item menu-item--expanded"
>
<span>О нас</span>
<ul class="menu">
<li class="menu-item"
>
<a href="/company/about" data-drupal-link-system-path="node/135">О компании</a>
</li>
<li class="menu-item"
>
<a href="/company/team" data-drupal-link-system-path="node/1045">О команде</a>
</li>
<li class="menu-item"
>
<a href="/company/licenses" data-drupal-link-system-path="node/333">Лицензии и сертификаты</a>
</li>
<li class="menu-item menu-item--collapsed"
>
<a href="/blog" data-drupal-link-system-path="blog/all">Блог</a>
</li>
<li class="menu-item footer-d-none"
>
<a href="/contacts" item="footer-d-none" data-drupal-link-system-path="node/763">Контакты</a>
</li>
</ul>
</li>
</ul>
</div>
<div class="col-12 col-lg-3 offset-lg-0 order-lg-3 col-xxl-3 offset-xxl-1 order-xxl-3 contact">
<div class="clearfix text-formatted field field--name-body field--type-text-with-summary field--label-hidden field__item"><div class="contact-wrapper" itemscope itemtype="https://schema.org/Organization">
<div itemprop="name" class="hidden d-none">НУБЕС (Nubes)</div>
<div class="title"><a href="/contacts">Контакты</a></div>
<ul class="contact-info-list" itemprop="contactPoint" itemscope itemtype="http://schema.org/ContactPoint">
<li class="hidden d-none" itemprop="contactType">Office</li>
<li class="phone callibri--phone" itemprop="telephone"><a href="tel:+74957894135" onclick="ym(68913385,'reachGoal','click_phone'); return true;">+7 (495) 789-4135</a></li>
<li class="email" itemprop="email"><a href="mailto:info@nubes.ru" onclick="ym(68913385,'reachGoal','сlicking_on_e-mail'); return true;">info@nubes.ru</a></li>
</ul>
<ul class="contact-info-list">
<li class="address" itemprop="address" itemscope itemtype="https://schema.org/PostalAddress"><span itemprop="postalCode">115404</span><span itemprop="addressCountry" class="hidden d-none">Россия</span>, <span itemprop="addressLocality">Москва</span>, <span itemprop="streetAddress"><span class="nowrap">1-я Стекольная улица</span>, д. 7, стр. 2</span></li>
</ul>
<div class="title">Техподдержка</div>
<ul class="contact-info-list" itemprop="contactPoint" itemscope itemtype="http://schema.org/ContactPoint">
<li class="hidden d-none" itemprop="contactType">Technical support</li>
<li class="phone" itemprop="telephone"><a href="tel:+74957894187">+7 (495) 789-4187</a></li>
<li class="email" itemprop="email"><a href="mailto:support@nubes.ru" onclick="ym(68913385,'reachGoal','e-mail_technical_support'); return true;">support@nubes.ru</a></li>
</ul>
</div></div>
</div>
<div class="col-12 col-lg-3 order-lg-1 col-xxl-4 order-xxl-1">
<div class="row">
<div class="col-12 col-md-6 col-lg-12 copyright">
<div class="clearfix text-formatted field field--name-body field--type-text-with-summary field--label-hidden field__item"><div class="copyright-wrapper">
<div class="logo">
<a href="/" title="Главная" rel="home" class=""><span class="site-logo" title="Главная"></span></a>
</div>
<div class="year">© 2020 - 2026. ООО «Нубес»</div>
</div></div>
</div>
<div class="col-12 col-md-6 col-lg-12">
<div class="row">
<div class="col-12 socnet">
<div class="clearfix text-formatted field field--name-body field--type-text-with-summary field--label-hidden field__item">
<div class="socnet-wrapper">
<div class="social-icon">
<a class="icon telegram" href="https://t.me/nubes_ru" target="_blank">Telegram</a>
<a class="icon habr" href="https://habr.com/ru/companies/nubes/articles/" target="_blank">Habr</a>
<a class="icon vk" href="https://vk.com/cloudnubes" target="_blank">VK</a>
<a class="icon youtube" href="https://www.youtube.com/@it-nubes" target="_blank">YouTube</a>
<a class="icon phone" href="tel:+74957894135" onclick="ym(68913385,'reachGoal','click_phone'); return true;">+7 (495) 789-4135</a>
<a class="icon email" href="mailto:info@nubes.ru" onclick="ym(68913385,'reachGoal','сlicking_on_e-mail'); return true;">info@nubes.ru</a>
</div>
</div></div>
</div>
<div class="col-12 politics">
<div class="clearfix text-formatted field field--name-body field--type-text-with-summary field--label-hidden field__item"><div class="politic-wrapper">
<div class="politic"><a href="/privacy">Политика конфиденциальности</a></div>
<div class="licenses"><a href="/company/licenses">Лицензии и&nbsp;сертификаты</a></div>
</div></div>
</div>
</div>
</div>
</div>
</div>
</div></div>
</div>
</div>
</div>
</footer>
</div>
</div>
<script type="application/json" data-drupal-selector="drupal-settings-json">{"path":{"baseUrl":"\/","pathPrefix":"","currentPath":"","currentPathIsAdmin":false,"isFront":false,"currentLanguage":"ru"},"pluralDelimiter":"\u0003","suppressDeprecationErrors":true,"ajaxPageState":{"libraries":"eJyFkAFuwyAMRS9EYKq08yCTuJTUwRk2W7rTj25Rmy6rJiHx_3-2hQnQn71yO7MLd-1Tz9mEJ3AUE5hVtMD82ojgg7_JbpSOQFF0yyNxAOpEL5Ry3JIJRSCimJ6JS-DFDXiESnoLfMqtCd0vf-fEMLgH11hBl7JiyUB2fKtYLvbIZfohuSmg9IkGF23Dzm4odW6VqzU5ekrxpNf3bLTJNaD4w8tht9IHhut8t94WRlh2IRJOmNUOqJBIrMA7_lukHCM9L1s_cMe_t_0rtFLDlNRz7vELtwfLbQ","theme":"nubes_2025","theme_token":null},"ajaxTrustedUrl":{"form_action_p_pvdeGsVG5zNF_XLGPTvYSKCf43t8qZYSwcfZl2uzM":true,"\/system\/404?destination=\/\u0026_exception_statuscode=404\u0026ajax_form=1":true},"back_to_top":{"back_to_top_button_trigger":100,"back_to_top_speed":1200,"back_to_top_prevent_on_mobile":false,"back_to_top_prevent_in_admin":false,"back_to_top_button_type":"image","back_to_top_button_text":"\u041d\u0430\u0432\u0435\u0440\u0445"},"colorbox":{"opacity":"0.85","current":"{current} \u0438\u0437 {total}","previous":"\u00ab \u041f\u0440\u0435\u0434\u044b\u0434\u0443\u0449\u0438\u0439","next":"\u0421\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0439 \u00bb","close":"\u0417\u0430\u043a\u0440\u044b\u0442\u044c","maxWidth":"98%","maxHeight":"98%","fixed":true,"mobiledetect":true,"mobiledevicewidth":"480px"},"data":{"extlink":{"extTarget":true,"extTargetAppendNewWindowDisplay":true,"extTargetAppendNewWindowLabel":"(opens in a new window)","extTargetNoOverride":true,"extNofollow":true,"extTitleNoOverride":false,"extNoreferrer":true,"extFollowNoOverride":true,"extClass":"0","extLabel":"(\u0432\u043d\u0435\u0448\u043d\u044f\u044f \u0441\u0441\u044b\u043b\u043a\u0430)","extImgClass":false,"extSubdomains":true,"extExclude":"","extInclude":"","extCssExclude":"","extCssInclude":"","extCssExplicit":"","extAlert":false,"extAlertText":"\u042d\u0442\u0430 \u0441\u0441\u044b\u043b\u043a\u0430 \u0432\u0435\u0434\u0451\u0442 \u043d\u0430 \u0432\u043d\u0435\u0448\u043d\u0438\u0439 \u0432\u0435\u0431-\u0441\u0430\u0439\u0442. \u041c\u044b \u043d\u0435 \u043d\u0435\u0441\u0451\u043c \u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043d\u043d\u043e\u0441\u0442\u0438 \u0437\u0430 \u0435\u0433\u043e \u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435.","extHideIcons":false,"mailtoClass":"0","telClass":"","mailtoLabel":"(link sends email)","telLabel":"(link is a phone number)","extUseFontAwesome":false,"extIconPlacement":"append","extPreventOrphan":false,"extFaLinkClasses":"fa fa-external-link","extFaMailtoClasses":"fa fa-envelope-o","extAdditionalLinkClasses":"","extAdditionalMailtoClasses":"","extAdditionalTelClasses":"","extFaTelClasses":"fa fa-phone","allowedDomains":[],"extExcludeNoreferrer":""}},"ajax":{"edit-actions-submit":{"callback":"::submitAjaxForm","event":"click","effect":"none","speed":500,"progress":{"type":"fullscreen","message":""},"disable-refocus":true,"url":"\/system\/404?destination=\/\u0026_exception_statuscode=404\u0026ajax_form=1","httpMethod":"POST","dialogType":"ajax","submit":{"_triggering_element_name":"op","_triggering_element_value":"\u041f\u043e\u0434\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f"}}},"user":{"uid":0,"permissionsHash":"fb32f6d7076721adcd4aad6923188bc589668cd9f49a20dcb0dce8ae7855298b"}}</script>
<script src="/files/js/js_h7vU87ZQu_K-2jVerZgYfkoh63tbMAtBN2UAg19it-Y.js?scope=footer&amp;delta=0&amp;language=ru&amp;theme=nubes_2025&amp;include=eJyFj2uKwzAMhC_kWEuh5zFyohqnipW15Da9_XqXQtg-KAikmW_mhyKO52DSZ4W432FWF0VMreJ6hP0cZh0YjdTcKCw1ygYTnbDxboRcOBeCB71zFpzgn-qsEuRiVAuyn78b1Zs_SV0cbdbbZ5hqWzu6S1daJA2Hr8MREktEHtRunSR3pfhbhPv2OOP2ZBLTQsX8RIaZ1Ste6GPIJCV-H1tIFdMz_3vjlem1xSVbkDLSD-hwmME"></script>
<!-- Yandex.Metrika counter -->
<script type="text/javascript" >
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym(68913385, "init", {
clickmap:true,
trackLinks:true,
accurateTrackBounce:true,
webvisor:true
});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/68913385" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
<!--script src="//cdn.callibri.ru/callibri.js" type="text/javascript" charset="utf-8"></script-->
<script async src="//widgets.mango-office.ru/site/36934" type="text/javascript"></script>
<script>
(function(w, d, u, i, o, s, p) {
if (d.getElementById(i)) { return; } w['MangoObject'] = o;
w[o] = w[o] || function() { (w[o].q = w[o].q || []).push(arguments) }; w[o].u = u; w[o].t = 1 * new Date();
s = d.createElement('script'); s.async = 1; s.id = i; s.src = u; s.charset = 'utf-8';
p = d.getElementsByTagName('script')[0]; p.parentNode.insertBefore(s, p);
}(window, document, '//widgets.mango-office.ru/widgets/mango.js', 'mango-js', 'mgo'));
mgo({multichannel: {id: 32020}});
</script>
<!--
Разработка и поддержка: xTech
Development & Support: xTech
-->
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
:root {
--bg: #f5f5f5; --card: #fff; --text: #1a1a1a; --muted: #6b7280;
--border: #d1d5db; --grey-light: #f3f4f6;
--blue: #2563eb; --blue-h: #1d4ed8; --green: #16a34a; --green-h: #15803d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text);
font-size: 14px; line-height: 1.5;
}
.tag {
font-size: .75rem; padding: 2px 10px; border-radius: 10px;
background: var(--grey-light); color: var(--muted);
border: 1px solid var(--border);
}
.page { max-width: 1100px; margin: 1.5rem auto; padding: 0 1rem; display: flex; gap: 1rem; }
.main { flex: 1; min-width: 0; }
.side { width: 272px; flex-shrink: 0; }
.card {
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,.04);
margin-bottom: 1rem; overflow: hidden;
}
.card-header {
background: var(--grey-light); padding: .75rem 1rem;
font-weight: 600; font-size: 1rem; border-bottom: 1px solid var(--border);
}
.card-body { padding: 1rem; }
.status { font-size: .85rem; color: var(--muted); margin-top: .5rem; min-height: 1.2rem; }
.btn {
display: inline-flex; align-items: center; gap: .35rem;
padding: .5rem 1rem; border: 1px solid var(--border);
border-radius: 6px; font-size: .85rem; font-weight: 500;
background: #fff; color: var(--text); cursor: pointer;
transition: background .15s, opacity .15s;
white-space: nowrap; font-family: inherit;
}
.btn:hover { background: var(--grey-light); }
.btn-primary { background: var(--blue); color: #fff; border-color: var(--blue); }
.btn-primary:hover { background: var(--blue-h); }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; border-color: #93c5fd; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
/* Chat */
.msg { padding: .75rem 1rem; border-radius: 8px; margin-bottom: .5rem; font-size: .9rem; line-height: 1.6; }
.msg.user { background: #eff6ff; border: 1px solid #dbeafe; color: #1e40af; }
.msg.bot { background: var(--grey-light); border: 1px solid var(--border); color: var(--text); }
.msg p { margin: 0; } .msg p + p { margin-top: .5rem; }
.msg b { color: #111; }
.msg code { background: #e5e7eb; padding: 1px 5px; border-radius: 3px; font-size: .85em; color: #b91c1c; }
.msg ul, .msg ol { margin: .25rem 0; padding-left: 1.25rem; }
.msg li { margin: .1rem 0; }
/* History */
.hitem { padding: 6px 8px; border-radius: 4px; cursor: pointer; margin-bottom: 3px; font-size: .8rem; color: var(--muted); }
.hitem:hover { background: var(--grey-light); }
/* DARK */
body.dark {
--bg: #0f0f1a; --card: #18182a; --text: #e0e0e0; --muted: #888;
--border: #2a2a40; --grey-light: #1e1e30;
}
body.dark header { background: var(--card) !important; border-color: var(--border) !important; color: var(--muted) !important; }
body.dark header b { color: var(--text) !important; }
body.dark .msg.user { background: #0a1e2e; border: 1px solid #1e3a5f; color: #93c5fd; }
body.dark .msg.bot { background: var(--grey-light); border: 1px solid var(--border); color: #d0d0f0; }
body.dark .msg b { color: #fff; }
body.dark .msg code { background: #2a2a44; color: #f8c291; }
body.dark .btn { background: var(--card); color: var(--text); }
body.dark .btn:hover { background: var(--border); }
body.dark .btn-primary { background: var(--blue); color: #fff; }
body.dark .btn-primary:hover { background: var(--blue-h); }
-4
View File
@@ -1,4 +0,0 @@
Flask==2.0.1
Werkzeug==2.3.7
requests>=2.31
gunicorn>=21.2
+76
View File
@@ -0,0 +1,76 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const FormData = require('form-data');
const app = express();
const PORT = process.env.PORT || 3000;
const API_KEY = process.env.API_KEY || '';
const API_BASE = 'https://api.aillm.ru';
const upload = multer({ storage: multer.memoryStorage() });
// ====== Models ======
const MODELS = {
'gpt-oss-120b': { id: 'gpt-oss-120b', name: 'GPT-OSS-120B', desc: '120B reasoning', temp: 0.7, tokens: 4096, system: 'Ты — полезный ассистент. Отвечай на том же языке, что и запрос.' },
'qwen3.6-27b-fp8': { id: 'qwen3.6-27b-fp8', name: 'Qwen3.6-27B', desc: 'Легкая 27B', temp: 0.7, tokens: 4096, system: 'Ты — полезный ассистент. Отвечай на том же языке, что и запрос.' },
};
// ====== Static ======
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
// ====== Routes ======
app.get('/healthz', (_req, res) => res.json({ status: 'ok' }));
app.get('/api/profiles', (_req, res) => {
res.json(Object.entries(MODELS).map(([id, m]) => ({ id, name: m.name, desc: m.desc })));
});
app.post('/api/whisper', upload.single('audio'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No audio file' });
const form = new FormData();
form.append('file', req.file.buffer, { filename: 'audio.webm', contentType: req.file.mimetype || 'audio/webm' });
form.append('model', 'whisper-large-v3-turbo');
const r = await fetch(`${API_BASE}/v1/audio/transcriptions`, {
method: 'POST', headers: { Authorization: `Bearer ${API_KEY}`, ...form.getHeaders() }, body: form,
});
const data = await r.json();
res.json({ text: data.text || '' });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post('/api/chat', async (req, res) => {
try {
const { model, message, history } = req.body;
const m = MODELS[model];
if (!m) return res.status(400).json({ error: 'Неизвестная модель' });
const fullMessages = [
{ role: 'system', content: m.system },
...(history || []),
{ role: 'user', content: message || 'Привет' },
];
// Обрезаем чтобы не выйти за лимит
while (JSON.stringify(fullMessages).length > 32000 && fullMessages.length > 2)
fullMessages.splice(1, 2);
const r = await fetch(`${API_BASE}/v1/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: m.id, temperature: m.temp, max_tokens: m.tokens, messages: fullMessages }),
});
const data = await r.json();
const msg = data.choices?.[0]?.message || {};
res.json({ response: msg.content || msg.reasoning_content || 'Нет ответа' });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
app.listen(PORT, () => console.log(`Say:${PORT}`));
+23 -45
View File
@@ -1,55 +1,33 @@
""" from flask import Flask, render_template, jsonify
Say — голосовой ассистент с Whisper + LLM.
Точка входа. Создаёт Flask-приложение, регистрирует Blueprints,
настраивает логирование и запускает сервер.
"""
import logging
import sys
from flask import Flask
from config import FLASK_HOST, FLASK_PORT, LOG_LEVEL
from routes import pages_bp, api_bp
def create_app() -> Flask: class SayApp:
""" def __init__(self):
Фабрика Flask-приложения. self.app = Flask(__name__, template_folder="templates", static_folder="static")
self.add_routes()
Собирает приложение из Blueprint'ов: def add_routes(self):
- pages_bp — HTML-страницы (/, /health) self.app.add_url_rule("/", "index", self.index)
- api_bp — REST API (/api/*) self.app.add_url_rule("/healthz", "healthz", self.health)
""" self.app.add_url_rule("/api/profiles", "profiles", self.profiles)
app = Flask(__name__)
# --- Blueprints --- def index(self):
app.register_blueprint(pages_bp) return render_template("index.html")
app.register_blueprint(api_bp)
# --- Логирование --- def healthz(self):
logging.basicConfig( return jsonify({"status": "ok"})
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
stream=sys.stdout,
)
return app def profiles(self):
return jsonify([
{"id": "ddm", "name": "DDM-120", "desc": "Быстрая модель"},
{"id": "qwen", "name": "Qwen", "desc": "Мощная модель"},
{"id": "deepseek", "name": "DeepSeek", "desc": "Глубокая аналитика"},
])
def run(self):
self.app.run(host="0.0.0.0", port=5000, debug=True)
# =============================================================================
# Запуск
# =============================================================================
if __name__ == "__main__": if __name__ == "__main__":
application = create_app() SayApp().run()
logging.info("Say server starting on %s:%s", FLASK_HOST, FLASK_PORT)
application.run(
host=FLASK_HOST,
port=FLASK_PORT,
debug=False,
)
else:
# WSGI-сервер (gunicorn) импортирует `application`
application = create_app()
-87
View File
@@ -1,87 +0,0 @@
"""
Централизованная конфигурация приложения Say.
Все настройки читаются из переменных окружения с разумными значениями по умолчанию.
"""
import os
# =============================================================================
# API — единый эндпоинт (aillm.ru)
# =============================================================================
API_BASE = os.environ.get("API_BASE", "https://api.aillm.ru")
API_KEY = os.environ.get("API_KEY", "") # ⚠️ Без ключа — только через env!
# =============================================================================
# Whisper (распознавание речи)
# =============================================================================
WHISPER_URL = os.environ.get(
"WHISPER_URL",
f"{API_BASE}/v1/audio/transcriptions",
)
WHISPER_KEY = os.environ.get("WHISPER_KEY", API_KEY)
WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "whisper-large-v3")
# =============================================================================
# Три LLM-профиля — каждый со своим именем, моделью и системным промптом
# =============================================================================
LLM_PROFILES = [
{
"id": "ddm",
"name": "DDM-120",
"description": "Быстрая модель, общие вопросы",
"url": os.environ.get("LLM_URL_DDM", f"{API_BASE}/v1/chat/completions"),
"key": os.environ.get("LLM_KEY_DDM", API_KEY),
"model": os.environ.get("LLM_MODEL_DDM", "ddm-120"),
"system_prompt": os.environ.get(
"LLM_SYSTEM_DDM",
"Ты — полезный ассистент. Отвечай кратко и по делу на том же языке, что и запрос.",
),
"temperature": float(os.environ.get("LLM_TEMP_DDM", "0.7")),
"max_tokens": int(os.environ.get("LLM_TOKENS_DDM", "1024")),
},
{
"id": "qwen",
"name": "Qwen",
"description": "Мощная модель, сложные задачи",
"url": os.environ.get("LLM_URL_QWEN", f"{API_BASE}/v1/chat/completions"),
"key": os.environ.get("LLM_KEY_QWEN", API_KEY),
"model": os.environ.get("LLM_MODEL_QWEN", "qwen-plus"),
"system_prompt": os.environ.get(
"LLM_SYSTEM_QWEN",
"Ты — эксперт-аналитик. Отвечай развернуто, структурированно, на языке запроса.",
),
"temperature": float(os.environ.get("LLM_TEMP_QWEN", "0.7")),
"max_tokens": int(os.environ.get("LLM_TOKENS_QWEN", "2048")),
},
{
"id": "deepseek",
"name": "DeepSeek",
"description": "Глубокая аналитика, код, рассуждения",
"url": os.environ.get("LLM_URL_DEEPSEEK", f"{API_BASE}/v1/chat/completions"),
"key": os.environ.get("LLM_KEY_DEEPSEEK", API_KEY),
"model": os.environ.get("LLM_MODEL_DEEPSEEK", "deepseek-chat"),
"system_prompt": os.environ.get(
"LLM_SYSTEM_DEEPSEEK",
"Ты — глубокий аналитик и программист. Думай step-by-step, отвечай на языке запроса.",
),
"temperature": float(os.environ.get("LLM_TEMP_DEEPSEEK", "0.7")),
"max_tokens": int(os.environ.get("LLM_TOKENS_DEEPSEEK", "4096")),
},
]
# =============================================================================
# Flask
# =============================================================================
FLASK_HOST = os.environ.get("HOST", "0.0.0.0")
FLASK_PORT = int(os.environ.get("PORT", "5000"))
# =============================================================================
# Логирование
# =============================================================================
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
-11
View File
@@ -1,11 +0,0 @@
"""
Роуты приложения Say.
pages.py — HTML-страницы (index, health).
api.py — REST API (список профилей, отправка речи).
"""
from .pages import pages_bp
from .api import api_bp
__all__ = ["pages_bp", "api_bp"]
-92
View File
@@ -1,92 +0,0 @@
"""
API-роуты.
GET /api/profiles — список доступных LLM-профилей.
POST /api/speech/<llm_id> — принять аудио, распознать, отправить в LLM, вернуть ответ.
"""
import logging
from flask import Blueprint, request, jsonify
from services.whisper import whisper_service
from services.llm import llm_service
log = logging.getLogger(__name__)
api_bp = Blueprint("api", __name__, url_prefix="/api")
@api_bp.route("/profiles")
def get_profiles():
"""Возвращает список LLM-профилей для отображения на фронтенде."""
return jsonify(llm_service.profile_list)
@api_bp.route("/speech/<llm_id>", methods=["POST"])
def speech_to_llm(llm_id: str):
"""
Основной рабочий процесс:
1. Принимает аудиофайл из формы (поле "audio").
2. Отправляет в Whisper → получает распознанный текст.
3. Отправляет текст в выбранный LLM-профиль → получает ответ.
4. Возвращает JSON: { transcription, response, llm }.
Args:
llm_id: Идентификатор LLM-профиля (ddm, qwen, deepseek, ...).
"""
# --- Валидация входных данных ---
if "audio" not in request.files:
return jsonify({"error": "Поле 'audio' обязательно"}), 400
audio_file = request.files["audio"]
audio_bytes = audio_file.read()
mime_type = audio_file.mimetype or "audio/webm"
if len(audio_bytes) == 0:
return jsonify({"error": "Аудиофайл пуст"}), 400
# --- Проверка существования LLM-профиля ---
profile = llm_service.get_profile(llm_id)
if not profile:
return jsonify({
"error": f"Неизвестный LLM: '{llm_id}'",
"available": [p["id"] for p in llm_service.profile_list],
}), 400
try:
# Шаг 1: Whisper — распознавание речи
log.info("API /speech/%s: получено %d байт аудио", llm_id, len(audio_bytes))
transcription = whisper_service.transcribe(audio_bytes, mime_type)
if not transcription:
return jsonify({
"error": "Не удалось распознать речь",
"transcription": "",
}), 400
# Шаг 2: LLM — отправка распознанного текста
llm_answer = llm_service.chat(llm_id, transcription)
# Шаг 3: Ответ
return jsonify({
"transcription": transcription,
"response": llm_answer,
"llm": {
"id": profile["id"],
"name": profile["name"],
},
})
except ValueError as e:
log.warning("API /speech/%s: bad request — %s", llm_id, e)
return jsonify({"error": str(e)}), 400
except RuntimeError as e:
log.error("API /speech/%s: runtime error — %s", llm_id, e)
return jsonify({"error": str(e)}), 500
except Exception as e:
log.exception("API /speech/%s: unexpected error", llm_id)
return jsonify({"error": f"Внутренняя ошибка: {e}"}), 500
-22
View File
@@ -1,22 +0,0 @@
"""
Роуты HTML-страниц.
GET / — главная страница (интерфейс с микрофоном).
GET /health — проверка живости сервера.
"""
from flask import Blueprint, render_template, jsonify
pages_bp = Blueprint("pages", __name__)
@pages_bp.route("/")
def index():
"""Главная страница с тремя кнопками записи (по одной на каждый ЛЛМ)."""
return render_template("index.html")
@pages_bp.route("/health")
def health():
"""Проверка работоспособности сервера."""
return jsonify({"status": "ok"})
-11
View File
@@ -1,11 +0,0 @@
"""
Сервисы приложения Say.
WhisperService — распознавание речи (speech-to-text).
LLMService — обращение к языковой модели (text-to-response).
"""
from .whisper import WhisperService
from .llm import LLMService
__all__ = ["WhisperService", "LLMService"]
-123
View File
@@ -1,123 +0,0 @@
"""
LLMService — обращение к языковой модели через OpenAI-совместимый API.
Поддерживает несколько профилей (разные модели/промпты) —
пользователь выбирает, к какому ЛЛМ отправить запрос.
"""
import logging
from typing import Dict, Optional
import requests
from config import LLM_PROFILES
log = logging.getLogger(__name__)
class LLMService:
"""
Сервис общения с языковой моделью.
Хранит набор профилей (model + system_prompt + температура + ...).
Метод chat() принимает id профиля и текст пользователя, возвращает ответ.
"""
def __init__(self, profiles: list = None):
"""
Args:
profiles: Список словарей с ключами:
id, name, description, url, key, model,
system_prompt, temperature, max_tokens.
По умолчанию — LLM_PROFILES из config.py.
"""
self._profiles: Dict[str, dict] = {}
for p in (profiles or LLM_PROFILES):
pid = p["id"]
self._profiles[pid] = p
log.info("LLM: зарегистрирован профиль '%s' (модель=%s)", pid, p["model"])
@property
def profile_list(self) -> list:
"""
Возвращает список профилей для фронтенда
(без ключей, только публичная информация).
"""
return [
{
"id": p["id"],
"name": p["name"],
"description": p["description"],
}
for p in self._profiles.values()
]
def get_profile(self, profile_id: str) -> Optional[dict]:
"""Возвращает полный профиль по id или None."""
return self._profiles.get(profile_id)
def chat(self, profile_id: str, user_text: str) -> str:
"""
Отправляет текст пользователя в указанную LLM и возвращает ответ.
Args:
profile_id: Идентификатор профиля (ddm, qwen, deepseek, ...).
user_text: Текст, который нужно отправить модели.
Returns:
Ответ модели (строка).
Raises:
ValueError: Если профиль не найден.
RuntimeError: При ошибке API.
"""
profile = self._profiles.get(profile_id)
if not profile:
available = ", ".join(self._profiles.keys())
raise ValueError(
f"Неизвестный LLM-профиль '{profile_id}'. Доступны: {available}"
)
if not profile["key"]:
raise RuntimeError(f"API-ключ для профиля '{profile_id}' не задан")
log.info(
"LLM [%s]: отправка запроса (модель=%s, %d символов)",
profile_id,
profile["model"],
len(user_text),
)
resp = requests.post(
profile["url"],
headers={
"Authorization": f"Bearer {profile['key']}",
"Content-Type": "application/json",
},
json={
"model": profile["model"],
"messages": [
{"role": "system", "content": profile["system_prompt"]},
{"role": "user", "content": user_text},
],
"temperature": profile["temperature"],
"max_tokens": profile["max_tokens"],
},
timeout=60,
)
if resp.status_code != 200:
log.error("LLM [%s] API error %d: %s", profile_id, resp.status_code, resp.text)
raise RuntimeError(
f"Ошибка LLM ({resp.status_code}): {resp.text[:200]}"
)
answer = resp.json()["choices"][0]["message"]["content"].strip()
log.info("LLM [%s]: получен ответ (%d символов)", profile_id, len(answer))
return answer
# Глобальный экземпляр сервиса
llm_service = LLMService()
-120
View File
@@ -1,120 +0,0 @@
"""
WhisperService — распознавание речи через OpenAI-совместимый API.
Принимает аудио-байты и MIME-тип, возвращает распознанный текст.
"""
import logging
import tempfile
from typing import Optional
import requests
from config import WHISPER_URL, WHISPER_KEY, WHISPER_MODEL
log = logging.getLogger(__name__)
class WhisperService:
"""
Сервис транскрибации аудио в текст.
Использует OpenAI-совместимый эндпоинт /v1/audio/transcriptions.
Поддерживает форматы: webm, wav, mp3, ogg, flac.
"""
def __init__(
self,
api_url: str = WHISPER_URL,
api_key: str = WHISPER_KEY,
model: str = WHISPER_MODEL,
):
"""
Args:
api_url: URL эндпоинта транскрибации.
api_key: API-ключ (Bearer-токен).
model: Название модели Whisper.
"""
self._url = api_url
self._key = api_key
self._model = model
def transcribe(
self,
audio_bytes: bytes,
mime_type: str = "audio/webm",
language: str = "ru",
) -> str:
"""
Отправляет аудио на распознавание и возвращает текст.
Args:
audio_bytes: Сырые байты аудиофайла.
mime_type: MIME-тип аудио (audio/webm, audio/wav, ...).
language: Язык распознавания (ISO 639-1).
Returns:
Распознанный текст (может быть пустой строкой).
Raises:
RuntimeError: При ошибке API или отсутствии ключа.
"""
if not self._key:
raise RuntimeError("API-ключ для Whisper не задан (WHISPER_KEY)")
# Определяем расширение файла по MIME-типу
suffix = self._mime_to_suffix(mime_type)
# Пишем аудио во временный файл (requests требует file-like object)
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(audio_bytes)
tmp.flush()
tmp.seek(0)
log.info(
"Whisper: отправка %d байт (тип=%s, модель=%s)",
len(audio_bytes),
mime_type,
self._model,
)
resp = requests.post(
self._url,
headers={"Authorization": f"Bearer {self._key}"},
files={"file": (f"audio{suffix}", tmp, mime_type)},
data={
"model": self._model,
"language": language,
},
timeout=30,
)
if resp.status_code != 200:
log.error("Whisper API error %d: %s", resp.status_code, resp.text)
raise RuntimeError(
f"Ошибка распознавания ({resp.status_code}): {resp.text[:200]}"
)
result = resp.json()
text = result.get("text", "").strip()
log.info("Whisper: распознано %d символов", len(text))
return text
@staticmethod
def _mime_to_suffix(mime_type: str) -> str:
"""Сопоставляет MIME-тип с расширением файла."""
mapping = {
"audio/webm": ".webm",
"audio/wav": ".wav",
"audio/wave": ".wav",
"audio/mp3": ".mp3",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/flac": ".flac",
}
return mapping.get(mime_type, ".webm")
# Глобальный экземпляр сервиса (переиспользуется между запросами)
whisper_service = WhisperService()
-2
View File
@@ -1,2 +0,0 @@
Forbidden
Transaction ID: bdd7fbca-219a-4dbf-95ca-a27cc8bdecd9
-241
View File
@@ -1,241 +0,0 @@
/*
* Say — Голосовой ассистент
* Дизайн-система Nubes (как в ipwhitelist-app)
*/
/* ==========================================================================
CSS Variables — точь-в-точь ipwhitelist-app
========================================================================== */
:root {
--bg: #f5f5f5;
--card: #ffffff;
--text: #1a1a1a;
--muted: #6b7280;
--border: #d1d5db;
--grey-light: #f3f4f6;
--blue: #2563eb;
--blue-h: #1d4ed8;
--red: #dc2626;
--red-h: #b91c1c;
--green: #16a34a;
--amber: #d97706;
}
/* ==========================================================================
Reset & Base
========================================================================== */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
}
/* ==========================================================================
Topbar (header) — 48px, белый, как в ipwhitelist-app
========================================================================== */
.topbar {
background: #fff;
border-bottom: 1px solid var(--border);
height: 48px;
display: flex;
align-items: center;
padding: 0 1.5rem;
}
.topbar-inner {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
}
.topbar-logo {
display: flex;
align-items: center;
text-decoration: none;
}
.topbar-logo img {
display: block;
}
.topbar-sep {
color: var(--muted);
font-size: 1rem;
user-select: none;
}
.topbar-title {
font-size: 0.9rem;
color: var(--muted);
}
/* ==========================================================================
Page Container
========================================================================== */
.page {
max-width: 900px;
margin: 1.5rem auto;
padding: 0 1rem;
}
/* ==========================================================================
Cards — точь-в-точь ipwhitelist-app
========================================================================== */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,.04);
margin-bottom: 1rem;
overflow: hidden;
}
.card-header {
background: var(--grey-light);
padding: .75rem 1rem;
font-weight: 600;
font-size: 1rem;
border-bottom: 1px solid var(--border);
}
.card-body {
padding: 1rem;
}
/* ==========================================================================
Alerts — точь-в-точь ipwhitelist-app
========================================================================== */
.alert {
padding: .75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
border: 1px solid;
}
.alert-error {
background: #fecaca;
color: #991b1b;
border-color: #fca5a5;
}
.alert-success {
background: #dcfce7;
color: #166534;
border-color: #bbf7d0;
}
/* ==========================================================================
Buttons — точь-в-точь ipwhitelist-app
========================================================================== */
.btn {
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .5rem 1rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: .85rem;
font-weight: 500;
background: #fff;
cursor: pointer;
transition: background .15s;
white-space: nowrap;
}
.btn:hover { background: var(--grey-light); }
.btn-primary {
background: var(--blue);
color: #fff;
border-color: var(--blue);
}
.btn-primary:hover { background: var(--blue-h); }
.btn-primary:disabled {
background: #93c5fd;
border-color: #93c5fd;
cursor: not-allowed;
}
/* ==========================================================================
LLM Cards — список моделей
========================================================================== */
.llm-card {
display: flex;
align-items: center;
gap: 1rem;
padding: .75rem 0;
border-bottom: 1px solid var(--border);
}
.llm-card:last-child { border-bottom: none; }
.llm-info {
flex: 1;
min-width: 0;
}
.llm-name {
font-weight: 600;
font-size: .95rem;
}
.llm-desc {
font-size: .8rem;
color: var(--muted);
margin-top: .15rem;
}
.rec-status {
font-size: .8rem;
color: var(--muted);
min-width: 80px;
text-align: right;
}
/* Кнопка в режиме записи */
.record-btn.recording {
background: var(--red);
border-color: var(--red);
animation: pulse-rec 1.2s infinite;
}
.record-btn.recording:hover {
background: var(--red-h);
}
@keyframes pulse-rec {
0%, 100% { box-shadow: 0 0 0 0 rgba(220,38,38,.4); }
50% { box-shadow: 0 0 0 6px rgba(220,38,38,0); }
}
/* ==========================================================================
Result Section
========================================================================== */
.result-section {
margin-bottom: 1rem;
}
.result-section:last-child { margin-bottom: 0; }
.result-label {
font-size: .8rem;
font-weight: 500;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .5px;
margin-bottom: .35rem;
}
.result-text {
font-size: 1rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
/* ==========================================================================
Loading
========================================================================== */
.loading {
color: var(--muted);
font-size: .9rem;
padding: 1rem 0;
}
/* ==========================================================================
Footer — точь-в-точь ipwhitelist-app
========================================================================== */
.footer {
text-align: center;
font-size: .72rem;
color: var(--muted);
padding: 1rem;
}
-283
View File
@@ -1,283 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Say — Голосовой ассистент | Nubes</title>
<link rel="icon" href="/static/favicon.png" type="image/png">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- ================================================================== -->
<!-- Header — точь-в-точь как в ipwhitelist-app -->
<!-- ================================================================== -->
<header class="topbar">
<div class="topbar-inner">
<a href="/" class="topbar-logo" title="Say — Главная">
<img src="/static/nubes-logo.svg" alt="Nubes" width="130" height="28">
</a>
<span class="topbar-sep">|</span>
<span class="topbar-title">Say — Голосовой ассистент</span>
</div>
</header>
<!-- ================================================================== -->
<!-- Контент -->
<!-- ================================================================== -->
<div class="page">
<!-- Карточка: три LLM на выбор -->
<div class="card">
<div class="card-header">🤖 Выберите модель — нажмите и говорите</div>
<div class="card-body" id="llmCards">
<!-- Заполняется JS после загрузки профилей с /api/profiles -->
<div class="loading">Загрузка моделей...</div>
</div>
</div>
<!-- Карточка: результат -->
<div class="card" id="resultCard" style="display:none">
<div class="card-header">📋 Результат</div>
<div class="card-body">
<div class="result-section">
<div class="result-label">📝 Распознано (Whisper)</div>
<div class="result-text" id="transcriptionText"></div>
</div>
<div class="result-section">
<div class="result-label" id="responseLabel">🤖 Ответ LLM</div>
<div class="result-text" id="responseText"></div>
</div>
</div>
</div>
<!-- Ошибка -->
<div class="alert alert-error" id="errorBox" style="display:none"></div>
</div>
<!-- ================================================================== -->
<!-- Footer -->
<!-- ================================================================== -->
<div class="footer">Say v1.0.0 · Whisper + LLM · Nubes</div>
<!-- ================================================================== -->
<!-- JavaScript -->
<!-- ================================================================== -->
<script>
// ===========================================================================
// DOM-элементы
// ===========================================================================
const llmCards = document.getElementById('llmCards');
const resultCard = document.getElementById('resultCard');
const transEl = document.getElementById('transcriptionText');
const respEl = document.getElementById('responseText');
const respLabel = document.getElementById('responseLabel');
const errorBox = document.getElementById('errorBox');
// ===========================================================================
// Состояние записи (одна активная кнопка в моменте)
// ===========================================================================
let activeStream = null; // MediaStream
let activeRecorder = null; // MediaRecorder
let activeBtn = null; // DOM-кнопка
let audioChunks = [];
let activeLlmId = null;
// ===========================================================================
// Шаблон карточки LLM
// ===========================================================================
function buildLlmCard(profile) {
return `
<div class="llm-card" data-llm="${profile.id}">
<div class="llm-info">
<div class="llm-name">${escapeHtml(profile.name)}</div>
<div class="llm-desc">${escapeHtml(profile.description)}</div>
</div>
<button class="btn btn-primary record-btn" data-llm="${profile.id}">
<span class="btn-icon">🎤</span>
<span class="btn-label">Записать</span>
</button>
<span class="rec-status" data-llm="${profile.id}"></span>
</div>
`;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ===========================================================================
// Загрузка профилей с сервера
// ===========================================================================
async function loadProfiles() {
try {
const resp = await fetch('/api/profiles');
if (!resp.ok) throw new Error('Ошибка загрузки профилей: ' + resp.status);
const profiles = await resp.json();
llmCards.innerHTML = profiles.map(buildLlmCard).join('');
// Вешаем обработчики на все кнопки записи
document.querySelectorAll('.record-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var llmId = btn.dataset.llm;
if (activeRecorder && activeRecorder.state === 'recording') {
stopRecording(); // повторное нажатие = стоп
} else {
startRecording(llmId, btn);
}
});
});
} catch (err) {
llmCards.innerHTML = '<div class="alert alert-error">' + escapeHtml(err.message) + '</div>';
}
}
// ===========================================================================
// Старт записи
// ===========================================================================
async function startRecording(llmId, btn) {
// Сбрасываем предыдущий результат/ошибку
resultCard.style.display = 'none';
errorBox.style.display = 'none';
// Если уже идёт запись на другой кнопке — стоп
if (activeRecorder && activeRecorder.state === 'recording') {
stopRecording();
}
try {
activeStream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
showError('Нет доступа к микрофону. Разрешите запись в браузере.');
return;
}
var mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
activeRecorder = new MediaRecorder(activeStream, { mimeType: mimeType });
audioChunks = [];
activeLlmId = llmId;
activeBtn = btn;
activeRecorder.ondataavailable = function(e) {
if (e.data.size > 0) audioChunks.push(e.data);
};
activeRecorder.onstop = function() { sendToServer(); };
activeRecorder.start();
// UI: кнопка → состояние «запись»
setButtonRecording(btn, true);
setAllButtonsDisabled(true, btn);
}
// ===========================================================================
// Стоп записи
// ===========================================================================
function stopRecording() {
if (activeRecorder && activeRecorder.state === 'recording') {
activeRecorder.stop();
}
if (activeStream) {
activeStream.getTracks().forEach(function(t) { t.stop(); });
activeStream = null;
}
// UI: кнопка → «обработка...»
if (activeBtn) {
setButtonProcessing(activeBtn);
}
}
// ===========================================================================
// Отправка аудио на сервер
// ===========================================================================
async function sendToServer() {
var blob = new Blob(audioChunks, { type: 'audio/webm' });
var formData = new FormData();
formData.append('audio', blob, 'recording.webm');
try {
var resp = await fetch('/api/speech/' + activeLlmId, {
method: 'POST',
body: formData,
});
var data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || 'Ошибка сервера (' + resp.status + ')');
}
// Успех — показываем результат
transEl.textContent = data.transcription;
respEl.textContent = data.response;
respLabel.textContent = '🤖 Ответ (' + data.llm.name + ')';
resultCard.style.display = '';
errorBox.style.display = 'none';
} catch (err) {
showError(err.message);
} finally {
resetButtons();
}
}
// ===========================================================================
// UI-хелперы
// ===========================================================================
function setButtonRecording(btn, rec) {
var icon = btn.querySelector('.btn-icon');
var label = btn.querySelector('.btn-label');
if (rec) {
icon.textContent = '⏺️';
label.textContent = 'Стоп';
btn.classList.add('recording');
}
}
function setButtonProcessing(btn) {
var icon = btn.querySelector('.btn-icon');
var label = btn.querySelector('.btn-label');
icon.textContent = '⏳';
label.textContent = 'Обработка...';
btn.disabled = true;
}
function setAllButtonsDisabled(dis, exceptBtn) {
document.querySelectorAll('.record-btn').forEach(function(b) {
if (b !== exceptBtn) b.disabled = dis;
});
}
function resetButtons() {
document.querySelectorAll('.record-btn').forEach(function(b) {
b.disabled = false;
b.classList.remove('recording');
b.querySelector('.btn-icon').textContent = '🎤';
b.querySelector('.btn-label').textContent = 'Записать';
});
activeBtn = null;
activeRecorder = null;
activeLlmId = null;
}
function showError(msg) {
errorBox.textContent = '❌ ' + msg;
errorBox.style.display = '';
resultCard.style.display = 'none';
}
// ===========================================================================
// Инициализация
// ===========================================================================
loadProfiles();
</script>
</body>
</html>