minimal: только Whisper, один файл server.js
This commit is contained in:
@@ -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>
|
||||||
@@ -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
@@ -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;
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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}`);
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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>
|
|
||||||
Reference in New Issue
Block a user