From a321541578283dfb2d65ffad863f2186e9baa1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sun, 31 May 2026 15:39:33 +0300 Subject: [PATCH] =?UTF-8?q?Say=20v1.0.0:=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=B0=D1=81=D1=81=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=20Whisper=20+=203=20LLM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + Dockerfile | 16 +++ app.py | 55 ++++++++ config.py | 87 +++++++++++++ requirements.txt | 3 + routes/__init__.py | 11 ++ routes/api.py | 92 ++++++++++++++ routes/pages.py | 22 ++++ services/__init__.py | 11 ++ services/llm.py | 123 ++++++++++++++++++ services/whisper.py | 120 ++++++++++++++++++ static/favicon.png | Bin 0 -> 1017 bytes static/nubes-logo.svg | 2 + static/style.css | 241 +++++++++++++++++++++++++++++++++++ templates/index.html | 283 ++++++++++++++++++++++++++++++++++++++++++ 15 files changed, 1072 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 config.py create mode 100644 requirements.txt create mode 100644 routes/__init__.py create mode 100644 routes/api.py create mode 100644 routes/pages.py create mode 100644 services/__init__.py create mode 100644 services/llm.py create mode 100644 services/whisper.py create mode 100644 static/favicon.png create mode 100644 static/nubes-logo.svg create mode 100644 static/style.css create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43108c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.env +venv/ +.venv/ +*.egg-info/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7e6f6f5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..360d140 --- /dev/null +++ b/app.py @@ -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() diff --git a/config.py b/config.py new file mode 100644 index 0000000..d1fabad --- /dev/null +++ b/config.py @@ -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") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3c63ab8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +requests>=2.31 +gunicorn>=21.2 diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..54f9ee8 --- /dev/null +++ b/routes/__init__.py @@ -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"] diff --git a/routes/api.py b/routes/api.py new file mode 100644 index 0000000..773ede2 --- /dev/null +++ b/routes/api.py @@ -0,0 +1,92 @@ +""" +API-роуты. + +GET /api/profiles — список доступных LLM-профилей. +POST /api/speech/ — принять аудио, распознать, отправить в LLM, вернуть ответ. +""" + +import logging + +from flask import Blueprint, request, jsonify + +from services.whisper import whisper_service +from services.llm import llm_service + +log = logging.getLogger(__name__) + +api_bp = Blueprint("api", __name__, url_prefix="/api") + + +@api_bp.route("/profiles") +def get_profiles(): + """Возвращает список LLM-профилей для отображения на фронтенде.""" + return jsonify(llm_service.profile_list) + + +@api_bp.route("/speech/", methods=["POST"]) +def speech_to_llm(llm_id: str): + """ + Основной рабочий процесс: + + 1. Принимает аудиофайл из формы (поле "audio"). + 2. Отправляет в Whisper → получает распознанный текст. + 3. Отправляет текст в выбранный LLM-профиль → получает ответ. + 4. Возвращает JSON: { transcription, response, llm }. + + Args: + llm_id: Идентификатор LLM-профиля (ddm, qwen, deepseek, ...). + """ + # --- Валидация входных данных --- + if "audio" not in request.files: + return jsonify({"error": "Поле 'audio' обязательно"}), 400 + + audio_file = request.files["audio"] + audio_bytes = audio_file.read() + mime_type = audio_file.mimetype or "audio/webm" + + if len(audio_bytes) == 0: + return jsonify({"error": "Аудиофайл пуст"}), 400 + + # --- Проверка существования LLM-профиля --- + profile = llm_service.get_profile(llm_id) + if not profile: + return jsonify({ + "error": f"Неизвестный LLM: '{llm_id}'", + "available": [p["id"] for p in llm_service.profile_list], + }), 400 + + try: + # Шаг 1: Whisper — распознавание речи + log.info("API /speech/%s: получено %d байт аудио", llm_id, len(audio_bytes)) + transcription = whisper_service.transcribe(audio_bytes, mime_type) + + if not transcription: + return jsonify({ + "error": "Не удалось распознать речь", + "transcription": "", + }), 400 + + # Шаг 2: LLM — отправка распознанного текста + llm_answer = llm_service.chat(llm_id, transcription) + + # Шаг 3: Ответ + return jsonify({ + "transcription": transcription, + "response": llm_answer, + "llm": { + "id": profile["id"], + "name": profile["name"], + }, + }) + + except ValueError as e: + log.warning("API /speech/%s: bad request — %s", llm_id, e) + return jsonify({"error": str(e)}), 400 + + except RuntimeError as e: + log.error("API /speech/%s: runtime error — %s", llm_id, e) + return jsonify({"error": str(e)}), 500 + + except Exception as e: + log.exception("API /speech/%s: unexpected error", llm_id) + return jsonify({"error": f"Внутренняя ошибка: {e}"}), 500 diff --git a/routes/pages.py b/routes/pages.py new file mode 100644 index 0000000..6a54c6e --- /dev/null +++ b/routes/pages.py @@ -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"}) diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..22f8e08 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,11 @@ +""" +Сервисы приложения Say. + +WhisperService — распознавание речи (speech-to-text). +LLMService — обращение к языковой модели (text-to-response). +""" + +from .whisper import WhisperService +from .llm import LLMService + +__all__ = ["WhisperService", "LLMService"] diff --git a/services/llm.py b/services/llm.py new file mode 100644 index 0000000..5cca470 --- /dev/null +++ b/services/llm.py @@ -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() diff --git a/services/whisper.py b/services/whisper.py new file mode 100644 index 0000000..ef9f0b4 --- /dev/null +++ b/services/whisper.py @@ -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() diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..f171012130744e0901c3ea67d8687f902f8ff017 GIT binary patch literal 1017 zcmVpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H11BOXN zK~#90?VHbwn?)GMKi|nFRH0%kR@UZS{DD7CMQeAru81sxQc){PdlPFfp1k@8co6aA zNfwG15j}V)qIeZVDBVO5^dv&*ZWcjp6lH=fGPl3IF3Xfy<0M5R&mFlSh>+1>W zWQpX$F;b2p=D_YF)jPIUdGG(2OapY!Bac!qy^QQh#KV-2+zB`kVAo#IzM+i56KQb| z$@;UTJcl%o>|V;;hOwoWYj32z&Gp(0P%>ACVgYTf4@O{_pqVG`r+oG28-q1(-5GO$ zjo!S_*er%t9lgQ!nei|{g*3G4co@(FxdE{S#AZAUkb)o9<6%G#;TlixW=P{s6z4M(}^~eP{1_$vd{LMsqR4 z0D0!N?)L6<&}^0-@{V=-qovpuAU6G4(jo6o2aOu9c*myhVso+GfRx|;TC#mQc=Q?X zjt7Z!!aH`l{%v!y-2lh;{v9_ykxu&O-vCfD|1Ox{jy17i)#^s`rHnhkeeB;mcTm3Y zgS5EEyG|d`%8W<@Mi`%)cpVD34Vo3;UJKeueQN zy0HteBhG!$)>hVe0VT6%ftLcbH9(iZ{zTU37;P>E{_djvBBaxprn?$A0J>tU%eQR5 zn!Q!_->Yv>EWJqg3#9_s0pI}Uqc3D*hv;_b06067+1gGgMwHBd1nH{*UF|;1{$cIA z+s&-L{iB_~;o9g>Wi9xk~7u?56t;tlu@QVsOr)$)WX00000NkvXXu0mjfOU&;) literal 0 HcmV?d00001 diff --git a/static/nubes-logo.svg b/static/nubes-logo.svg new file mode 100644 index 0000000..1a7aed4 --- /dev/null +++ b/static/nubes-logo.svg @@ -0,0 +1,2 @@ +Forbidden +Transaction ID: bdd7fbca-219a-4dbf-95ca-a27cc8bdecd9 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..9a5690f --- /dev/null +++ b/static/style.css @@ -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; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..ffb940c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,283 @@ + + + + + + Say — Голосовой ассистент | Nubes + + + + + + + + +
+
+ + | + Say — Голосовой ассистент +
+
+ + + + +
+ + +
+
🤖 Выберите модель — нажмите и говорите
+
+ +
Загрузка моделей...
+
+
+ + + + + + + +
+ + + + + + + + + + + +