Say v1.0.0: голосовой ассистент Whisper + 3 LLM
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Зависимости
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Код
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Порт
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Запуск через gunicorn (production) или flask (dev)
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "90", "app:application"]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""
|
||||||
|
Централизованная конфигурация приложения Say.
|
||||||
|
|
||||||
|
Все настройки читаются из переменных окружения с разумными значениями по умолчанию.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API — единый эндпоинт (aillm.ru)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
API_BASE = os.environ.get("API_BASE", "https://api.aillm.ru")
|
||||||
|
API_KEY = os.environ.get("API_KEY", "sk-ucI5YvOticoOQ9Kuj5K9mQ")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 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")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0
|
||||||
|
requests>=2.31
|
||||||
|
gunicorn>=21.2
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
Роуты приложения 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"]
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
API-роуты.
|
||||||
|
|
||||||
|
GET /api/profiles — список доступных LLM-профилей.
|
||||||
|
POST /api/speech/<llm_id> — принять аудио, распознать, отправить в LLM, вернуть ответ.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
|
||||||
|
from services.whisper import whisper_service
|
||||||
|
from services.llm import llm_service
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
api_bp = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/profiles")
|
||||||
|
def get_profiles():
|
||||||
|
"""Возвращает список LLM-профилей для отображения на фронтенде."""
|
||||||
|
return jsonify(llm_service.profile_list)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/speech/<llm_id>", methods=["POST"])
|
||||||
|
def speech_to_llm(llm_id: str):
|
||||||
|
"""
|
||||||
|
Основной рабочий процесс:
|
||||||
|
|
||||||
|
1. Принимает аудиофайл из формы (поле "audio").
|
||||||
|
2. Отправляет в Whisper → получает распознанный текст.
|
||||||
|
3. Отправляет текст в выбранный LLM-профиль → получает ответ.
|
||||||
|
4. Возвращает JSON: { transcription, response, llm }.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
llm_id: Идентификатор LLM-профиля (ddm, qwen, deepseek, ...).
|
||||||
|
"""
|
||||||
|
# --- Валидация входных данных ---
|
||||||
|
if "audio" not in request.files:
|
||||||
|
return jsonify({"error": "Поле 'audio' обязательно"}), 400
|
||||||
|
|
||||||
|
audio_file = request.files["audio"]
|
||||||
|
audio_bytes = audio_file.read()
|
||||||
|
mime_type = audio_file.mimetype or "audio/webm"
|
||||||
|
|
||||||
|
if len(audio_bytes) == 0:
|
||||||
|
return jsonify({"error": "Аудиофайл пуст"}), 400
|
||||||
|
|
||||||
|
# --- Проверка существования LLM-профиля ---
|
||||||
|
profile = llm_service.get_profile(llm_id)
|
||||||
|
if not profile:
|
||||||
|
return jsonify({
|
||||||
|
"error": f"Неизвестный LLM: '{llm_id}'",
|
||||||
|
"available": [p["id"] for p in llm_service.profile_list],
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Шаг 1: Whisper — распознавание речи
|
||||||
|
log.info("API /speech/%s: получено %d байт аудио", llm_id, len(audio_bytes))
|
||||||
|
transcription = whisper_service.transcribe(audio_bytes, mime_type)
|
||||||
|
|
||||||
|
if not transcription:
|
||||||
|
return jsonify({
|
||||||
|
"error": "Не удалось распознать речь",
|
||||||
|
"transcription": "",
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Шаг 2: LLM — отправка распознанного текста
|
||||||
|
llm_answer = llm_service.chat(llm_id, transcription)
|
||||||
|
|
||||||
|
# Шаг 3: Ответ
|
||||||
|
return jsonify({
|
||||||
|
"transcription": transcription,
|
||||||
|
"response": llm_answer,
|
||||||
|
"llm": {
|
||||||
|
"id": profile["id"],
|
||||||
|
"name": profile["name"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
log.warning("API /speech/%s: bad request — %s", llm_id, e)
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
log.error("API /speech/%s: runtime error — %s", llm_id, e)
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("API /speech/%s: unexpected error", llm_id)
|
||||||
|
return jsonify({"error": f"Внутренняя ошибка: {e}"}), 500
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""
|
||||||
|
Роуты 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"})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
Сервисы приложения Say.
|
||||||
|
|
||||||
|
WhisperService — распознавание речи (speech-to-text).
|
||||||
|
LLMService — обращение к языковой модели (text-to-response).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .whisper import WhisperService
|
||||||
|
from .llm import LLMService
|
||||||
|
|
||||||
|
__all__ = ["WhisperService", "LLMService"]
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1017 B |
@@ -0,0 +1,2 @@
|
|||||||
|
Forbidden
|
||||||
|
Transaction ID: bdd7fbca-219a-4dbf-95ca-a27cc8bdecd9
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
/*
|
||||||
|
* Say — Голосовой ассистент
|
||||||
|
* Дизайн-система Nubes (как в ipwhitelist-app)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
CSS Variables — точь-в-точь ipwhitelist-app
|
||||||
|
========================================================================== */
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--border: #d1d5db;
|
||||||
|
--grey-light: #f3f4f6;
|
||||||
|
--blue: #2563eb;
|
||||||
|
--blue-h: #1d4ed8;
|
||||||
|
--red: #dc2626;
|
||||||
|
--red-h: #b91c1c;
|
||||||
|
--green: #16a34a;
|
||||||
|
--amber: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Reset & Base
|
||||||
|
========================================================================== */
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Topbar (header) — 48px, белый, как в ipwhitelist-app
|
||||||
|
========================================================================== */
|
||||||
|
.topbar {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
.topbar-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.topbar-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.topbar-logo img {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.topbar-sep {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.topbar-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Page Container
|
||||||
|
========================================================================== */
|
||||||
|
.page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Cards — точь-в-точь ipwhitelist-app
|
||||||
|
========================================================================== */
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
background: var(--grey-light);
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Alerts — точь-в-точь ipwhitelist-app
|
||||||
|
========================================================================== */
|
||||||
|
.alert {
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
.alert-error {
|
||||||
|
background: #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
.alert-success {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Buttons — точь-в-точь ipwhitelist-app
|
||||||
|
========================================================================== */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .35rem;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--grey-light); }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--blue);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--blue);
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--blue-h); }
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: #93c5fd;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
LLM Cards — список моделей
|
||||||
|
========================================================================== */
|
||||||
|
.llm-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: .75rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.llm-card:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.llm-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.llm-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
.llm-desc {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: .15rem;
|
||||||
|
}
|
||||||
|
.rec-status {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка в режиме записи */
|
||||||
|
.record-btn.recording {
|
||||||
|
background: var(--red);
|
||||||
|
border-color: var(--red);
|
||||||
|
animation: pulse-rec 1.2s infinite;
|
||||||
|
}
|
||||||
|
.record-btn.recording:hover {
|
||||||
|
background: var(--red-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-rec {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(220,38,38,.4); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(220,38,38,0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Result Section
|
||||||
|
========================================================================== */
|
||||||
|
.result-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.result-section:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.result-label {
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
margin-bottom: .35rem;
|
||||||
|
}
|
||||||
|
.result-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Loading
|
||||||
|
========================================================================== */
|
||||||
|
.loading {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .9rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Footer — точь-в-точь ipwhitelist-app
|
||||||
|
========================================================================== */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Say — Голосовой ассистент | Nubes</title>
|
||||||
|
<link rel="icon" href="/static/favicon.png" type="image/png">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- Header — точь-в-точь как в ipwhitelist-app -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-inner">
|
||||||
|
<a href="/" class="topbar-logo" title="Say — Главная">
|
||||||
|
<img src="/static/nubes-logo.svg" alt="Nubes" width="130" height="28">
|
||||||
|
</a>
|
||||||
|
<span class="topbar-sep">|</span>
|
||||||
|
<span class="topbar-title">Say — Голосовой ассистент</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- Контент -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<!-- Карточка: три LLM на выбор -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">🤖 Выберите модель — нажмите и говорите</div>
|
||||||
|
<div class="card-body" id="llmCards">
|
||||||
|
<!-- Заполняется JS после загрузки профилей с /api/profiles -->
|
||||||
|
<div class="loading">Загрузка моделей...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Карточка: результат -->
|
||||||
|
<div class="card" id="resultCard" style="display:none">
|
||||||
|
<div class="card-header">📋 Результат</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="result-section">
|
||||||
|
<div class="result-label">📝 Распознано (Whisper)</div>
|
||||||
|
<div class="result-text" id="transcriptionText"></div>
|
||||||
|
</div>
|
||||||
|
<div class="result-section">
|
||||||
|
<div class="result-label" id="responseLabel">🤖 Ответ LLM</div>
|
||||||
|
<div class="result-text" id="responseText"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ошибка -->
|
||||||
|
<div class="alert alert-error" id="errorBox" style="display:none"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- Footer -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<div class="footer">Say v1.0.0 · Whisper + LLM · Nubes</div>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- JavaScript -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<script>
|
||||||
|
// ===========================================================================
|
||||||
|
// DOM-элементы
|
||||||
|
// ===========================================================================
|
||||||
|
const llmCards = document.getElementById('llmCards');
|
||||||
|
const resultCard = document.getElementById('resultCard');
|
||||||
|
const transEl = document.getElementById('transcriptionText');
|
||||||
|
const respEl = document.getElementById('responseText');
|
||||||
|
const respLabel = document.getElementById('responseLabel');
|
||||||
|
const errorBox = document.getElementById('errorBox');
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Состояние записи (одна активная кнопка в моменте)
|
||||||
|
// ===========================================================================
|
||||||
|
let activeStream = null; // MediaStream
|
||||||
|
let activeRecorder = null; // MediaRecorder
|
||||||
|
let activeBtn = null; // DOM-кнопка
|
||||||
|
let audioChunks = [];
|
||||||
|
let activeLlmId = null;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Шаблон карточки LLM
|
||||||
|
// ===========================================================================
|
||||||
|
function buildLlmCard(profile) {
|
||||||
|
return `
|
||||||
|
<div class="llm-card" data-llm="${profile.id}">
|
||||||
|
<div class="llm-info">
|
||||||
|
<div class="llm-name">${escapeHtml(profile.name)}</div>
|
||||||
|
<div class="llm-desc">${escapeHtml(profile.description)}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary record-btn" data-llm="${profile.id}">
|
||||||
|
<span class="btn-icon">🎤</span>
|
||||||
|
<span class="btn-label">Записать</span>
|
||||||
|
</button>
|
||||||
|
<span class="rec-status" data-llm="${profile.id}"></span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Загрузка профилей с сервера
|
||||||
|
// ===========================================================================
|
||||||
|
async function loadProfiles() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/profiles');
|
||||||
|
if (!resp.ok) throw new Error('Ошибка загрузки профилей: ' + resp.status);
|
||||||
|
const profiles = await resp.json();
|
||||||
|
|
||||||
|
llmCards.innerHTML = profiles.map(buildLlmCard).join('');
|
||||||
|
|
||||||
|
// Вешаем обработчики на все кнопки записи
|
||||||
|
document.querySelectorAll('.record-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var llmId = btn.dataset.llm;
|
||||||
|
if (activeRecorder && activeRecorder.state === 'recording') {
|
||||||
|
stopRecording(); // повторное нажатие = стоп
|
||||||
|
} else {
|
||||||
|
startRecording(llmId, btn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
llmCards.innerHTML = '<div class="alert alert-error">' + escapeHtml(err.message) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Старт записи
|
||||||
|
// ===========================================================================
|
||||||
|
async function startRecording(llmId, btn) {
|
||||||
|
// Сбрасываем предыдущий результат/ошибку
|
||||||
|
resultCard.style.display = 'none';
|
||||||
|
errorBox.style.display = 'none';
|
||||||
|
|
||||||
|
// Если уже идёт запись на другой кнопке — стоп
|
||||||
|
if (activeRecorder && activeRecorder.state === 'recording') {
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
activeStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
} catch (err) {
|
||||||
|
showError('Нет доступа к микрофону. Разрешите запись в браузере.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||||
|
? 'audio/webm;codecs=opus'
|
||||||
|
: 'audio/webm';
|
||||||
|
|
||||||
|
activeRecorder = new MediaRecorder(activeStream, { mimeType: mimeType });
|
||||||
|
audioChunks = [];
|
||||||
|
activeLlmId = llmId;
|
||||||
|
activeBtn = btn;
|
||||||
|
|
||||||
|
activeRecorder.ondataavailable = function(e) {
|
||||||
|
if (e.data.size > 0) audioChunks.push(e.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
activeRecorder.onstop = function() { sendToServer(); };
|
||||||
|
|
||||||
|
activeRecorder.start();
|
||||||
|
|
||||||
|
// UI: кнопка → состояние «запись»
|
||||||
|
setButtonRecording(btn, true);
|
||||||
|
setAllButtonsDisabled(true, btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Стоп записи
|
||||||
|
// ===========================================================================
|
||||||
|
function stopRecording() {
|
||||||
|
if (activeRecorder && activeRecorder.state === 'recording') {
|
||||||
|
activeRecorder.stop();
|
||||||
|
}
|
||||||
|
if (activeStream) {
|
||||||
|
activeStream.getTracks().forEach(function(t) { t.stop(); });
|
||||||
|
activeStream = null;
|
||||||
|
}
|
||||||
|
// UI: кнопка → «обработка...»
|
||||||
|
if (activeBtn) {
|
||||||
|
setButtonProcessing(activeBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Отправка аудио на сервер
|
||||||
|
// ===========================================================================
|
||||||
|
async function sendToServer() {
|
||||||
|
var blob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('audio', blob, 'recording.webm');
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/api/speech/' + activeLlmId, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
var data = await resp.json();
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(data.error || 'Ошибка сервера (' + resp.status + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Успех — показываем результат
|
||||||
|
transEl.textContent = data.transcription;
|
||||||
|
respEl.textContent = data.response;
|
||||||
|
respLabel.textContent = '🤖 Ответ (' + data.llm.name + ')';
|
||||||
|
resultCard.style.display = '';
|
||||||
|
errorBox.style.display = 'none';
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
} finally {
|
||||||
|
resetButtons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// UI-хелперы
|
||||||
|
// ===========================================================================
|
||||||
|
function setButtonRecording(btn, rec) {
|
||||||
|
var icon = btn.querySelector('.btn-icon');
|
||||||
|
var label = btn.querySelector('.btn-label');
|
||||||
|
if (rec) {
|
||||||
|
icon.textContent = '⏺️';
|
||||||
|
label.textContent = 'Стоп';
|
||||||
|
btn.classList.add('recording');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setButtonProcessing(btn) {
|
||||||
|
var icon = btn.querySelector('.btn-icon');
|
||||||
|
var label = btn.querySelector('.btn-label');
|
||||||
|
icon.textContent = '⏳';
|
||||||
|
label.textContent = 'Обработка...';
|
||||||
|
btn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAllButtonsDisabled(dis, exceptBtn) {
|
||||||
|
document.querySelectorAll('.record-btn').forEach(function(b) {
|
||||||
|
if (b !== exceptBtn) b.disabled = dis;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetButtons() {
|
||||||
|
document.querySelectorAll('.record-btn').forEach(function(b) {
|
||||||
|
b.disabled = false;
|
||||||
|
b.classList.remove('recording');
|
||||||
|
b.querySelector('.btn-icon').textContent = '🎤';
|
||||||
|
b.querySelector('.btn-label').textContent = 'Записать';
|
||||||
|
});
|
||||||
|
activeBtn = null;
|
||||||
|
activeRecorder = null;
|
||||||
|
activeLlmId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorBox.textContent = '❌ ' + msg;
|
||||||
|
errorBox.style.display = '';
|
||||||
|
resultCard.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Инициализация
|
||||||
|
// ===========================================================================
|
||||||
|
loadProfiles();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user