From ce88c0c224aa38fcdc168854e132e583cb49d3fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sun, 31 May 2026 19:18:34 +0300 Subject: [PATCH] =?UTF-8?q?minimal:=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE?= =?UTF-8?q?=20Whisper,=20=D0=BE=D0=B4=D0=B8=D0=BD=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=20server.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/index.html | 114 +++++++++++++ server.js | 83 +++++++++- src/app.js | 29 ---- src/config.js | 54 ------- src/index.js | 12 -- src/middleware/errorHandler.js | 19 --- src/routes/api.js | 74 --------- src/routes/pages.js | 20 --- src/services/llm.js | 77 --------- src/services/whisper.js | 70 -------- templates/index.html | 283 --------------------------------- 11 files changed, 195 insertions(+), 640 deletions(-) create mode 100644 public/index.html delete mode 100644 src/app.js delete mode 100644 src/config.js delete mode 100644 src/index.js delete mode 100644 src/middleware/errorHandler.js delete mode 100644 src/routes/api.js delete mode 100644 src/routes/pages.js delete mode 100644 src/services/llm.js delete mode 100644 src/services/whisper.js delete mode 100644 templates/index.html diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..22a2961 --- /dev/null +++ b/public/index.html @@ -0,0 +1,114 @@ + + + + + + Say — Whisper test + + + + +
+
+ + | + Say — Whisper test +
+
+ +
+
+
🎤 Проверка Whisper
+
+ + +
+
+ + + + +
+ + + + + + diff --git a/server.js b/server.js index 2b4a02c..2a7fc3b 100644 --- a/server.js +++ b/server.js @@ -1,2 +1,81 @@ -// Точка входа для managed-платформы (ждёт index.js в корне) -require('./src/index.js'); +/** + * Say — минимальная версия (только Whisper). + * Работает: загрузка аудио → транскрибация → текст. + */ + +const express = require('express'); +const multer = require('multer'); +const fetch = require('node-fetch'); +const FormData = require('form-data'); +const path = require('path'); + +const PORT = parseInt(process.env.PORT, 10) || 3000; + +// --- Конфигурация Whisper --- +const WHISPER_URL = process.env.WHISPER_URL || 'https://api.aillm.ru/v1/audio/transcriptions'; +const WHISPER_MODEL = process.env.WHISPER_MODEL || 'whisper-large-v3'; +const API_KEY = process.env.API_KEY || ''; + +const upload = multer({ storage: multer.memoryStorage() }); +const app = express(); + +// --- Статика --- +app.use(express.static(path.join(__dirname, 'public'))); + +// --- Роуты --- + +// Главная +app.get('/', (_req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +// Health +app.get('/health', (_req, res) => { + res.json({ status: 'ok' }); +}); + +// Whisper: аудио → текст +app.post('/api/speech', upload.single('audio'), async (req, res) => { + try { + if (!req.file || !req.file.buffer || req.file.size === 0) { + return res.status(400).json({ error: 'Нет аудио' }); + } + + const form = new FormData(); + form.append('file', req.file.buffer, { + filename: 'audio.webm', + contentType: req.file.mimetype || 'audio/webm', + }); + form.append('model', WHISPER_MODEL); + form.append('language', 'ru'); + + console.log(`Whisper: отправка ${req.file.size} байт`); + + const resp = await fetch(WHISPER_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${API_KEY}` }, + body: form, + }); + + if (!resp.ok) { + const errText = await resp.text(); + console.error(`Whisper error ${resp.status}: ${errText}`); + return res.status(500).json({ error: `Whisper: ${resp.status}` }); + } + + const data = await resp.json(); + const text = (data.text || '').trim(); + + console.log(`Whisper: распознано "${text}"`); + res.json({ transcription: text }); + + } catch (err) { + console.error('ERROR:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +// --- Запуск --- +app.listen(PORT, () => { + console.log(`Say (Whisper only) listening on :${PORT}`); +}); diff --git a/src/app.js b/src/app.js deleted file mode 100644 index 8c60968..0000000 --- a/src/app.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * 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) — как в ipwhitelist: public/ → / --- - app.use(express.static(path.join(__dirname, '..', 'public'))); - - // --- Роуты --- - 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 deleted file mode 100644 index 738a117..0000000 --- a/src/config.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Централизованная конфигурация приложения 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 deleted file mode 100644 index 76453ea..0000000 --- a/src/index.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Точка входа. Запускает 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 deleted file mode 100644 index 688fb38..0000000 --- a/src/middleware/errorHandler.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Централизованный обработчик ошибок 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 deleted file mode 100644 index 0cfaa2c..0000000 --- a/src/routes/api.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * 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 deleted file mode 100644 index 871acb3..0000000 --- a/src/routes/pages.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Роуты 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 deleted file mode 100644 index fc786f9..0000000 --- a/src/services/llm.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * 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 deleted file mode 100644 index ad2c334..0000000 --- a/src/services/whisper.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * 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 }; diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 8412588..0000000 --- a/templates/index.html +++ /dev/null @@ -1,283 +0,0 @@ - - - - - - Say — Голосовой ассистент | Nubes - - - - - - - - -
-
- - | - Say — Голосовой ассистент -
-
- - - - -
- - -
-
🤖 Выберите модель — нажмите и говорите
-
- -
Загрузка моделей...
-
-
- - - - - - - -
- - - - - - - - - - - -