diff --git a/.gitignore b/.gitignore index 43108c6..c2658d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1 @@ -__pycache__/ -*.pyc -.env -venv/ -.venv/ -*.egg-info/ +node_modules/ diff --git a/Dockerfile b/Dockerfile index a69e5d3..d0306f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,11 @@ -FROM python:3.12-slim +FROM node:23-alpine WORKDIR /app -# Зависимости -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY package.json . +RUN npm install --production -# Код COPY . . -# Порт -EXPOSE 5000 - -# Запуск -CMD ["python", "app.py"] +EXPOSE 3000 +CMD ["node", "src/index.js"] diff --git a/app.py b/app.py deleted file mode 100644 index 360d140..0000000 --- a/app.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -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: - """ - Фабрика Flask-приложения. - - Собирает приложение из Blueprint'ов: - - pages_bp — HTML-страницы (/, /health) - - api_bp — REST API (/api/*) - """ - app = Flask(__name__) - - # --- Blueprints --- - app.register_blueprint(pages_bp) - app.register_blueprint(api_bp) - - # --- Логирование --- - logging.basicConfig( - level=getattr(logging, LOG_LEVEL.upper(), logging.INFO), - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - stream=sys.stdout, - ) - - return app - - -# ============================================================================= -# Запуск -# ============================================================================= - -if __name__ == "__main__": - application = create_app() - 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() diff --git a/config.py b/config.py deleted file mode 100644 index 4af27b7..0000000 --- a/config.py +++ /dev/null @@ -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") diff --git a/package.json b/package.json new file mode 100644 index 0000000..6648af1 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "say", + "version": "1.0.0", + "description": "Say — голосовой ассистент Whisper + LLM", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js" + }, + "dependencies": { + "express": "^4.21.0", + "multer": "^1.4.5-lts.1", + "node-fetch": "^2.7.0" + } +} diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 3c63ab8..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -flask>=3.0 -requests>=2.31 -gunicorn>=21.2 diff --git a/routes/__init__.py b/routes/__init__.py deleted file mode 100644 index 54f9ee8..0000000 --- a/routes/__init__.py +++ /dev/null @@ -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"] diff --git a/routes/api.py b/routes/api.py deleted file mode 100644 index 773ede2..0000000 --- a/routes/api.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -API-роуты. - -GET /api/profiles — список доступных LLM-профилей. -POST /api/speech/ — принять аудио, распознать, отправить в 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/", 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 diff --git a/routes/pages.py b/routes/pages.py deleted file mode 100644 index 6a54c6e..0000000 --- a/routes/pages.py +++ /dev/null @@ -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"}) diff --git a/services/__init__.py b/services/__init__.py deleted file mode 100644 index 22f8e08..0000000 --- a/services/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Сервисы приложения Say. - -WhisperService — распознавание речи (speech-to-text). -LLMService — обращение к языковой модели (text-to-response). -""" - -from .whisper import WhisperService -from .llm import LLMService - -__all__ = ["WhisperService", "LLMService"] diff --git a/services/llm.py b/services/llm.py deleted file mode 100644 index 5cca470..0000000 --- a/services/llm.py +++ /dev/null @@ -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() diff --git a/services/whisper.py b/services/whisper.py deleted file mode 100644 index ef9f0b4..0000000 --- a/services/whisper.py +++ /dev/null @@ -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() diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..adf92ab --- /dev/null +++ b/src/app.js @@ -0,0 +1,29 @@ +/** + * Express-приложение Say. + * Собирает middleware, роуты, отдаёт статику и шаблоны. + */ + +const express = require('express'); +const path = require('path'); + +const pagesRouter = require('./routes/pages'); +const apiRouter = require('./routes/api'); +const errorHandler = require('./middleware/errorHandler'); + +function createApp() { + const app = express(); + + // --- Статика (CSS, лого, favicon) --- + app.use('/static', express.static(path.join(__dirname, '..', 'static'))); + + // --- Роуты --- + app.use('/', pagesRouter); + app.use('/api', apiRouter); + + // --- Обработка ошибок --- + app.use(errorHandler); + + return app; +} + +module.exports = createApp; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..738a117 --- /dev/null +++ b/src/config.js @@ -0,0 +1,54 @@ +/** + * Централизованная конфигурация приложения Say. + * Все секреты — из переменных окружения, без хардкода. + */ + +const PORT = parseInt(process.env.PORT, 10) || 3000; + +// --- API (единый эндпоинт aillm.ru) --- +const API_BASE = process.env.API_BASE || 'https://api.aillm.ru'; +const API_KEY = process.env.API_KEY || ''; + +// --- Whisper --- +const WHISPER_URL = process.env.WHISPER_URL || `${API_BASE}/v1/audio/transcriptions`; +const WHISPER_MODEL = process.env.WHISPER_MODEL || 'whisper-large-v3'; + +// --- Три LLM-профиля --- +const LLM_PROFILES = [ + { + id: 'ddm', + name: 'DDM-120', + description: 'Быстрая модель, общие вопросы', + url: process.env.LLM_URL_DDM || `${API_BASE}/v1/chat/completions`, + model: process.env.LLM_MODEL_DDM || 'ddm-120', + systemPrompt: process.env.LLM_SYSTEM_DDM || 'Ты — полезный ассистент. Отвечай кратко и по делу на том же языке, что и запрос.', + temperature: parseFloat(process.env.LLM_TEMP_DDM || '0.7'), + maxTokens: parseInt(process.env.LLM_TOKENS_DDM, 10) || 1024, + }, + { + id: 'qwen', + name: 'Qwen', + description: 'Мощная модель, сложные задачи', + url: process.env.LLM_URL_QWEN || `${API_BASE}/v1/chat/completions`, + model: process.env.LLM_MODEL_QWEN || 'qwen-plus', + systemPrompt: process.env.LLM_SYSTEM_QWEN || 'Ты — эксперт-аналитик. Отвечай развернуто, структурированно, на языке запроса.', + temperature: parseFloat(process.env.LLM_TEMP_QWEN || '0.7'), + maxTokens: parseInt(process.env.LLM_TOKENS_QWEN, 10) || 2048, + }, + { + id: 'deepseek', + name: 'DeepSeek', + description: 'Глубокая аналитика, код, рассуждения', + url: process.env.LLM_URL_DEEPSEEK || `${API_BASE}/v1/chat/completions`, + model: process.env.LLM_MODEL_DEEPSEEK || 'deepseek-chat', + systemPrompt: process.env.LLM_SYSTEM_DEEPSEEK || 'Ты — глубокий аналитик и программист. Думай step-by-step, отвечай на языке запроса.', + temperature: parseFloat(process.env.LLM_TEMP_DEEPSEEK || '0.7'), + maxTokens: parseInt(process.env.LLM_TOKENS_DEEPSEEK, 10) || 4096, + }, +]; + +module.exports = { + PORT, API_BASE, API_KEY, + WHISPER_URL, WHISPER_MODEL, + LLM_PROFILES, +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..76453ea --- /dev/null +++ b/src/index.js @@ -0,0 +1,12 @@ +/** + * Точка входа. Запускает HTTP-сервер. + */ + +const createApp = require('./app'); +const { PORT } = require('./config'); + +const app = createApp(); + +app.listen(PORT, () => { + console.log(`Say server listening on :${PORT}`); +}); diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js new file mode 100644 index 0000000..688fb38 --- /dev/null +++ b/src/middleware/errorHandler.js @@ -0,0 +1,19 @@ +/** + * Централизованный обработчик ошибок Express. + * + * Ловит все ошибки, проброшенные через next(err), + * возвращает JSON с кодом и сообщением. + */ + +function errorHandler(err, _req, res, _next) { + console.error('ERROR:', err.message || err); + + // Определяем HTTP-статус + const status = err.status || err.statusCode || 500; + + res.status(status).json({ + error: err.message || 'Внутренняя ошибка сервера', + }); +} + +module.exports = errorHandler; diff --git a/src/routes/api.js b/src/routes/api.js new file mode 100644 index 0000000..0cfaa2c --- /dev/null +++ b/src/routes/api.js @@ -0,0 +1,74 @@ +/** + * API-роуты. + * + * GET /api/profiles — список LLM-профилей. + * POST /api/speech/:llmId — аудио → Whisper → LLM → ответ. + */ + +const { Router } = require('express'); +const multer = require('multer'); + +const { transcribe } = require('../services/whisper'); +const { getPublicProfiles, chat } = require('../services/llm'); + +const router = Router(); + +// Загрузка аудио в память (файлы маленькие — webm с микрофона) +const upload = multer({ storage: multer.memoryStorage() }); + +/** + * GET /api/profiles + * Возвращает публичную информацию о доступных LLM. + */ +router.get('/profiles', (_req, res) => { + res.json(getPublicProfiles()); +}); + +/** + * POST /api/speech/:llmId + * + * Принимает multipart/form-data с полем "audio". + * 1. Whisper → текст. + * 2. LLM (по profileId) → ответ. + * 3. JSON: { transcription, response, llm: { id, name } } + */ +router.post('/speech/:llmId', upload.single('audio'), async (req, res, next) => { + const { llmId } = req.params; + + // --- Валидация --- + if (!req.file || !req.file.buffer || req.file.size === 0) { + return res.status(400).json({ error: 'Аудиофайл пуст или отсутствует' }); + } + + const audioBuffer = req.file.buffer; + const mimeType = req.file.mimetype || 'audio/webm'; + + try { + // Шаг 1: Whisper + const transcription = await transcribe(audioBuffer, mimeType); + + if (!transcription) { + return res.status(400).json({ error: 'Не удалось распознать речь', transcription: '' }); + } + + // Шаг 2: LLM + const answer = await chat(llmId, transcription); + + // Шаг 3: Ответ + const { getPublicProfiles } = require('../services/llm'); + const profile = getPublicProfiles().find(p => p.id === llmId); + + res.json({ + transcription, + response: answer, + llm: { + id: profile ? profile.id : llmId, + name: profile ? profile.name : llmId, + }, + }); + } catch (err) { + next(err); + } +}); + +module.exports = router; diff --git a/src/routes/pages.js b/src/routes/pages.js new file mode 100644 index 0000000..871acb3 --- /dev/null +++ b/src/routes/pages.js @@ -0,0 +1,20 @@ +/** + * Роуты HTML-страниц. + * GET / — главная страница (интерфейс с микрофоном). + * GET /health — проверка живости. + */ + +const { Router } = require('express'); +const path = require('path'); + +const router = Router(); + +router.get('/', (_req, res) => { + res.sendFile(path.join(__dirname, '..', '..', 'templates', 'index.html')); +}); + +router.get('/health', (_req, res) => { + res.json({ status: 'ok' }); +}); + +module.exports = router; diff --git a/src/services/llm.js b/src/services/llm.js new file mode 100644 index 0000000..fc786f9 --- /dev/null +++ b/src/services/llm.js @@ -0,0 +1,77 @@ +/** + * LLMService — обращение к языковой модели. + * + * Поддерживает несколько профилей (разные модели/промпты). + * Пользователь выбирает профиль по id, сюда приходит текст — возвращается ответ. + */ + +const fetch = require('node-fetch'); + +const { LLM_PROFILES, API_KEY } = require('../config'); + +/** @type {Map} */ +const profiles = new Map(); +for (const p of LLM_PROFILES) { + profiles.set(p.id, p); + console.log(`LLM: зарегистрирован профиль '${p.id}' (${p.model})`); +} + +/** + * Публичный список профилей для фронтенда (без чувствительных полей). + */ +function getPublicProfiles() { + return LLM_PROFILES.map(p => ({ + id: p.id, + name: p.name, + description: p.description, + })); +} + +/** + * @param {string} profileId — id профиля (ddm, qwen, deepseek) + * @param {string} userText — текст пользователя + * @returns {Promise} — ответ модели + */ +async function chat(profileId, userText) { + const profile = profiles.get(profileId); + if (!profile) { + const available = [...profiles.keys()].join(', '); + throw new Error(`Неизвестный LLM-профиль '${profileId}'. Доступны: ${available}`); + } + + const key = API_KEY; + if (!key) throw new Error('API_KEY не задан в переменных окружения'); + + console.log(`LLM [${profileId}]: отправка (${userText.length} символов)`); + + const resp = await fetch(profile.url, { + method: 'POST', + headers: { + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: profile.model, + messages: [ + { role: 'system', content: profile.systemPrompt }, + { role: 'user', content: userText }, + ], + temperature: profile.temperature, + max_tokens: profile.maxTokens, + }), + timeout: 60000, + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`LLM ошибка ${resp.status}: ${text.slice(0, 200)}`); + } + + const data = await resp.json(); + const answer = data.choices[0].message.content.trim(); + + console.log(`LLM [${profileId}]: ответ (${answer.length} символов)`); + return answer; +} + +module.exports = { getPublicProfiles, chat }; diff --git a/src/services/whisper.js b/src/services/whisper.js new file mode 100644 index 0000000..ad2c334 --- /dev/null +++ b/src/services/whisper.js @@ -0,0 +1,70 @@ +/** + * WhisperService — распознавание речи (speech-to-text). + * + * Принимает буфер аудио + MIME-тип, + * отправляет на OpenAI-совместимый /v1/audio/transcriptions, + * возвращает распознанный текст. + */ + +const fetch = require('node-fetch'); +const FormData = require('form-data'); + +const { WHISPER_URL, WHISPER_MODEL, API_KEY } = require('../config'); + +/** + * @param {Buffer} audioBuffer — сырые байты аудио + * @param {string} mimeType — MIME-тип (audio/webm, audio/wav, ...) + * @param {string} language — язык распознавания (ISO 639-1) + * @returns {Promise} — распознанный текст + */ +async function transcribe(audioBuffer, mimeType = 'audio/webm', language = 'ru') { + if (!API_KEY) throw new Error('API_KEY не задан в переменных окружения'); + + const suffix = mimeToExt(mimeType); + + const form = new FormData(); + form.append('file', audioBuffer, { + filename: `audio${suffix}`, + contentType: mimeType, + }); + form.append('model', WHISPER_MODEL); + form.append('language', language); + + console.log(`Whisper: отправка ${audioBuffer.length} байт (${mimeType})`); + + const resp = await fetch(WHISPER_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${API_KEY}`, + }, + body: form, + timeout: 30000, + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Whisper ошибка ${resp.status}: ${text.slice(0, 200)}`); + } + + const data = await resp.json(); + const result = (data.text || '').trim(); + + console.log(`Whisper: распознано ${result.length} символов`); + return result; +} + +/** MIME → расширение файла */ +function mimeToExt(mime) { + const map = { + 'audio/webm': '.webm', + 'audio/wav': '.wav', + 'audio/wave': '.wav', + 'audio/mp3': '.mp3', + 'audio/mpeg': '.mp3', + 'audio/ogg': '.ogg', + 'audio/flac': '.flac', + }; + return map[mime] || '.webm'; +} + +module.exports = { transcribe };