minimal: только Whisper, один файл server.js

This commit is contained in:
2026-05-31 19:18:34 +03:00
parent a7bf18939c
commit ce88c0c224
11 changed files with 195 additions and 640 deletions
+114
View File
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Say — Whisper test</title>
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<a href="/" class="topbar-logo">
<img src="/nubes-logo.svg" alt="Nubes" width="130" height="28">
</a>
<span class="topbar-sep">|</span>
<span class="topbar-title">Say — Whisper test</span>
</div>
</header>
<div class="page">
<div class="card">
<div class="card-header">🎤 Проверка Whisper</div>
<div class="card-body">
<button id="recordBtn" class="btn btn-primary">
<span>🎤</span> Записать
</button>
<span id="status"></span>
</div>
</div>
<div class="card" id="resultCard" style="display:none">
<div class="card-header">📝 Результат</div>
<div class="card-body" id="result"></div>
</div>
<div id="error" class="alert alert-error" style="display:none"></div>
</div>
<div class="footer">Say v1.0.0 · Whisper</div>
<script>
const btn = document.getElementById('recordBtn');
const statusEl = document.getElementById('status');
const resultCard = document.getElementById('resultCard');
const resultEl = document.getElementById('result');
const errorEl = document.getElementById('error');
let recorder, stream, chunks = [], recording = false;
async function init() {
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
btn.disabled = false;
} catch (e) {
btn.textContent = 'Нет микрофона';
}
}
btn.onclick = () => {
if (!recording) start();
else stop();
};
function start() {
chunks = [];
recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
recorder.ondataavailable = e => { if (e.data.size) chunks.push(e.data); };
recorder.onstop = send;
recorder.start();
recording = true;
btn.innerHTML = '<span>⏺️</span> Стоп';
btn.classList.add('recording');
statusEl.textContent = '';
resultCard.style.display = 'none';
errorEl.style.display = 'none';
}
function stop() {
recorder.stop();
stream.getTracks().forEach(t => t.stop());
recording = false;
btn.innerHTML = '<span>⏳</span> Обработка...';
btn.disabled = true;
}
async function send() {
statusEl.textContent = 'Отправка...';
const blob = new Blob(chunks, { type: 'audio/webm' });
const fd = new FormData();
fd.append('audio', blob, 'rec.webm');
try {
const r = await fetch('/api/speech', { method: 'POST', body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.error);
resultEl.textContent = d.transcription || '(тишина)';
resultCard.style.display = '';
statusEl.textContent = 'Готово';
} catch (e) {
errorEl.textContent = '❌ ' + e.message;
errorEl.style.display = '';
} finally {
btn.innerHTML = '<span>🎤</span> Записать';
btn.disabled = false;
btn.classList.remove('recording');
init();
}
}
init();
</script>
</body>
</html>
+81 -2
View File
@@ -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}`);
});
-29
View File
@@ -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;
-54
View File
@@ -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,
};
-12
View File
@@ -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}`);
});
-19
View File
@@ -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;
-74
View File
@@ -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;
-20
View File
@@ -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;
-77
View File
@@ -1,77 +0,0 @@
/**
* LLMService — обращение к языковой модели.
*
* Поддерживает несколько профилей (разные модели/промпты).
* Пользователь выбирает профиль по id, сюда приходит текст — возвращается ответ.
*/
const fetch = require('node-fetch');
const { LLM_PROFILES, API_KEY } = require('../config');
/** @type {Map<string, object>} */
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<string>} — ответ модели
*/
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 };
-70
View File
@@ -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<string>} — распознанный текст
*/
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 };
-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="/favicon.png" type="image/png">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<!-- ================================================================== -->
<!-- Header — точь-в-точь как в ipwhitelist-app -->
<!-- ================================================================== -->
<header class="topbar">
<div class="topbar-inner">
<a href="/" class="topbar-logo" title="Say — Главная">
<img src="/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>