Первоначальная версия VidConf
This commit is contained in:
535
docs/architecture/README.md
Normal file
535
docs/architecture/README.md
Normal file
@@ -0,0 +1,535 @@
|
||||
# Архитектура
|
||||
|
||||
Высокоуровневый обзор дизайна системы VidConf.
|
||||
|
||||
## Быстрые ссылки
|
||||
|
||||
- **[frontend-themes.md](./frontend-themes.md)** — архитектура темы оболочки: механизм `data-theme`, localStorage, React-hook, анти-FOUC, инвариант комнаты.
|
||||
|
||||
## Архитектура системы
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Web Browsers │
|
||||
│ (React SPA + LiveKit JS SDK) │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ HTTPS
|
||||
┌──────▼───────┐
|
||||
│ Nginx │ (TLS termination)
|
||||
│ Reverse │
|
||||
│ Proxy │
|
||||
└──────┬───────┘
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
┌────▼────┐ ┌──────▼──────┐ ┌─────▼─────┐
|
||||
│ FastAPI │ │ LiveKit │ │ Coturn │
|
||||
│ Backend │ │ SFU │ │ TURN │
|
||||
└────┬────┘ └──────┬──────┘ └───────────┘
|
||||
│ │
|
||||
│ (TCP/Postgres) │ (RTP/SRTP WebRTC)
|
||||
┌────▼──────────────────┘
|
||||
│
|
||||
├──► PostgreSQL 16
|
||||
│ - 14 таблиц: users, teams, conferences, conference_sessions и т.д.
|
||||
│
|
||||
├──► Redis
|
||||
│ - Celery broker
|
||||
│ - Rate limiting, refresh-токены, pub/sub чата
|
||||
│
|
||||
└──► Celery воркеры (раздельные очереди)
|
||||
├─► transcription (faster-whisper small/medium/large-v3 → фразы)
|
||||
├─► summarize (Qwen3.5 4B/9B/35B-A3B → резюме, 3 уровня)
|
||||
└─► notify (email + .ics → SMTP)
|
||||
```
|
||||
|
||||
## Основные компоненты
|
||||
|
||||
### 1. Frontend (React SPA)
|
||||
**Location:** `frontend/`
|
||||
|
||||
**Tech:** React 18 + TypeScript + Vite, Tailwind CSS 4 + @tailwindcss/vite, shadcn/ui, `@livekit/components-react`, FullCalendar
|
||||
|
||||
**Функциональность:**
|
||||
- Аутентификация (вход, регистрация, верификация email), JWT (access — заголовок Authorization, refresh — httpOnly cookie)
|
||||
- Лобби-хаб, календарь (создание/редактирование/отмена конференций, повторение), «мои конференции»
|
||||
- Вход в конференцию по номеру/ссылке, гостевой вход
|
||||
- Комната конференции: видео/аудио через LiveKit JS SDK, чат в реальном времени, настройки устройств, fullscreen, Picture-in-Picture, демонстрация экрана
|
||||
- Профиль пользователя (ФИО, команда, аватар, смена пароля)
|
||||
- Админ-панель (конференции, пользователи, команды, настройки инстанса)
|
||||
- Светлая/тёмная тема оболочки (см. [frontend-themes.md](./frontend-themes.md))
|
||||
|
||||
**Коммуникация:**
|
||||
- HTTP + WebSocket → FastAPI backend (`/api/v1/*`)
|
||||
- WebRTC → LiveKit SFU
|
||||
|
||||
Подробнее: [frontend/README.md](../../frontend/README.md).
|
||||
|
||||
---
|
||||
|
||||
### 2. Backend (FastAPI)
|
||||
**Location:** `backend/`
|
||||
|
||||
**Tech:** Python 3.12, FastAPI, SQLAlchemy 2.0 async, Alembic
|
||||
|
||||
**Реализовано:**
|
||||
- Схема БД + миграции (14 таблиц + расширение `btree_gist`, установлено, но не используется текущими constraint'ами)
|
||||
- Контракты плагинов (Transcriber, Summarizer) + factory с реестром (`@register_transcriber`, `@register_summarizer`)
|
||||
- `NullTranscriber`, `NullSummarizer` (no-op), `FasterWhisperCPU`/`FasterWhisperGPU`, `QwenLocal`
|
||||
- JWT аутентификация (вход, регистрация, верификация email, refresh, logout), OAuth2 password flow
|
||||
- Генерация LiveKit токенов, приём и обработка webhook-событий LiveKit (room_started, participant_joined/left, room_finished)
|
||||
- Динамические конференции: создание (мгновенное/плановое), календарь, вход по номеру/ссылке/паролю, гостевой вход, повторение
|
||||
- Приглашения на конференцию (пользователь или внешний email), рассылка .ics
|
||||
- Чат конференции (WebSocket, история, broadcast через Redis pub/sub)
|
||||
- Администрирование: конференции, пользователи, команды, настройки инстанса
|
||||
- Endpoint `GET /api/health` (проверка БД/Redis), `GET /metrics` (Prometheus)
|
||||
|
||||
**API структура:**
|
||||
```
|
||||
/api/v1/
|
||||
├── auth/ register, verify-email, token, refresh, logout, registration-options
|
||||
├── users/ me (GET/PATCH), me/avatar (POST/DELETE), me/password, поиск (GET ?q=)
|
||||
├── teams/ справочник команд (GET)
|
||||
├── conferences/ POST create, GET /my, GET /calendar, GET /resolve,
|
||||
│ POST /{id}/join, POST /{id}/guest-join, GET/PATCH/DELETE /{id}
|
||||
├── conferences/{id}/chat WebSocket-чат
|
||||
├── admin/ conferences, users, teams, settings
|
||||
└── livekit/ webhook приёмник
|
||||
|
||||
/api/health статус системы (БД, Redis)
|
||||
/metrics метрики Prometheus
|
||||
```
|
||||
|
||||
Полная спецификация: [docs/api/README.md](../api/README.md).
|
||||
|
||||
---
|
||||
|
||||
### 3. Database (PostgreSQL 16)
|
||||
**Location:** миграции в `backend/alembic/versions/` (9 миграций)
|
||||
|
||||
**Основные таблицы:**
|
||||
|
||||
| Таблица | Назначение |
|
||||
|---------|---------|
|
||||
| `users` | Зарегистрированные пользователи (email, password_hash, role, is_blocked, team_id, avatar_path) |
|
||||
| `teams` | Справочник команд |
|
||||
| `email_verification_tokens` | Одноразовые токены верификации email |
|
||||
| `conferences` | Постоянные сущности конференций (номер, slug, владелец, статус, recurrence, summary_recipients, ics_sequence) |
|
||||
| `conference_invitees` | Приглашённые на конференцию (user_id ИЛИ email) |
|
||||
| `guest_access` | Гости, представившиеся при входе (display_name, email) |
|
||||
| `conference_sessions` | Один запуск конференции — единица AI-пайплайна (t_start, t_end, pipeline_status, summary_data) |
|
||||
| `conference_participants` | Отслеживание участия в сеансе (user_id ИЛИ guest_id, session_id, joined_at, left_at) |
|
||||
| `session_audio_tracks` | Аудиотреки сеанса (per-track запись, статус транскрибации, segments JSONB) |
|
||||
| `phrases` | Текстовые сегменты транскрибации (participant_id, session_id, t_start, t_end) |
|
||||
| `chat_messages` | Сообщения в сеансе (session_id, автор — user_id или guest_access_id, author_name) |
|
||||
| `email_deliveries` | Журнал отправленных писем (саммари, приглашения) — идемпотентность рассылки |
|
||||
| `instance_settings` | Настройки инстанса (key-value JSONB): AI-уровень, чат, таймзона, режим рассылки, опции регистрации |
|
||||
| `livekit_webhook_events` | Журнал webhook-событий для идемпотентности (event_id, event_type, received_at) |
|
||||
|
||||
**Ключевые особенности:**
|
||||
- **UUID первичные ключи** (кроме `phrases`/`chat_messages` → BIGINT Identity)
|
||||
- **Все времена в UTC** (`timestamptz`)
|
||||
- **Номер (9 цифр) и slug (base64url)** конференции — публичные идентификаторы доступа
|
||||
- **Recurrence как JSONB** (не RRULE; 4 типа: weekly/biweekly/monthly/every_n_days)
|
||||
- **Гости как полноценные участники** (`guest_id` в `conference_participants`)
|
||||
|
||||
См. [docs/db/schema.md](../db/schema.md) — полная ER-диаграмма и обоснования дизайна.
|
||||
|
||||
---
|
||||
|
||||
### 4. LiveKit SFU
|
||||
**Profile compose:** `media`
|
||||
|
||||
**Tech:** LiveKit (Go-based SFU)
|
||||
|
||||
**Роль:**
|
||||
- SFU (Selective Forwarding Unit) для WebRTC
|
||||
- Per-track аудио (один трек = один спикер, исключает необходимость диаризации)
|
||||
- Запись треков через Egress (`.ogg`, opus) для последующей транскрибации
|
||||
- FastAPI генерирует LiveKit-токены (JWT, TTL 6 часов, per-room), браузер подключается через LiveKit JS SDK
|
||||
|
||||
---
|
||||
|
||||
### 5. Celery Workers
|
||||
**Location:** `workers/`
|
||||
|
||||
**Tech:** Celery + Redis (message broker), раздельные очереди (`transcription`/`summarize`/`notify`)
|
||||
|
||||
**Задачи:**
|
||||
|
||||
| Задача | Вход | Процесс | Выход |
|
||||
|--------|-------|---------|--------|
|
||||
| `run_pipeline()` | session_id | Диспетчер + транскрибация (faster-whisper) + реконструкция фраз | `session_audio_tracks.segments`, `phrases` |
|
||||
| `summarize_session()` | session_id | Map-reduce Qwen3.5 (уровень из instance_settings) | `conference_sessions.summary_data` |
|
||||
| `notify_session()` | session_id | Генерация email + .ics, рассылка по режиму (all/owner) | `email_deliveries` |
|
||||
| `send_invitations()` | conference_id, emails | Генерация .ics, рассылка приглашений | `email_deliveries` |
|
||||
| `cleanup_conferences()` (beat) | — | Закрытие зависших сеансов, завершение просроченных конференций | — |
|
||||
| `recover_stuck_summaries()` / `recover_stuck_notifications()` (beat) | — | Переставить зависшую `summarize_session`/`notify_session` в очередь | завершение пайплайна |
|
||||
|
||||
**State machine pipeline_status:**
|
||||
```
|
||||
recording (создана при t_start сеанса, LiveKit Egress записывает)
|
||||
↓ (webhook room_finished → enqueue run_pipeline в очередь transcription)
|
||||
transcribing (транскрибация faster-whisper + реконструкция фраз)
|
||||
↓
|
||||
summarizing (map-reduce Qwen3.5 → очередь summarize; summary_data записан, статус не меняется)
|
||||
↓ (notify_session отправляет уведомления → очередь notify)
|
||||
notified
|
||||
↓ Успех: история сохранена в БД
|
||||
|
||||
↘ (при ошибке на любом шаге)
|
||||
↘ failed
|
||||
↘ Лог; beat-задачи recover_stuck_* могут переставить зависший шаг
|
||||
```
|
||||
|
||||
**Переход в notified:**
|
||||
- Собрать получателей по эффективному режиму рассылки (`conference.summary_recipients or cfg.summary_recipients`)
|
||||
- Режим `'all'` → участники сеанса (users) + гости с email
|
||||
- Режим `'owner'` → email владельца конференции
|
||||
- Отправить письмо для каждого нового адреса (таблица `email_deliveries` обеспечивает идемпотентность)
|
||||
- Переход в `notified` независимо от того, были ли получатели
|
||||
|
||||
**Идемпотентность:** каждый шаг проверяет `pipeline_status` перед началом и пропускает уже пройденные шаги.
|
||||
|
||||
Подробнее: [workers/README.md](../../workers/README.md).
|
||||
|
||||
---
|
||||
|
||||
### 6. AI Plugins (Strategy Pattern)
|
||||
**Location:** `backend/core/plugins/`
|
||||
|
||||
**Interfaces:**
|
||||
- `Transcriber.transcribe(audio_path, language) → list[Segment]`
|
||||
- `Summarizer.summarize(transcript) → str`
|
||||
|
||||
**Factory:**
|
||||
- Registry: `@register_transcriber`, `@register_summarizer`
|
||||
- Configuration: `config/plugins.yaml`
|
||||
- No-op defaults: `NullTranscriber`, `NullSummarizer`
|
||||
|
||||
**Расширяемость:**
|
||||
- Добавить нового провайдера = новый класс + строка в конфиге
|
||||
- Никаких изменений в основном коде
|
||||
|
||||
См. [docs/plugins/contracts.md](../plugins/contracts.md).
|
||||
|
||||
---
|
||||
|
||||
### 7. Nginx Reverse Proxy
|
||||
**Конфиг:** `deploy/nginx.conf`
|
||||
|
||||
**Задачи:**
|
||||
- Завершение TLS
|
||||
- Обслуживание статических файлов (frontend SPA)
|
||||
- Маршрутизация запросов API → FastAPI
|
||||
- Проксирование WebSocket (чат конференции)
|
||||
- Сжатие Gzip
|
||||
|
||||
---
|
||||
|
||||
## Поток данных
|
||||
|
||||
### 1. Пользователь входит в конференцию
|
||||
|
||||
```
|
||||
Browser
|
||||
│ 1. POST /api/v1/conferences/{id}/join (пароль опционально)
|
||||
├─────────────────────────────────► FastAPI
|
||||
│ │
|
||||
│ 2. Проверка доступа, генерация LiveKit-токена
|
||||
│ │
|
||||
│ 3. {"token": "...", "url": "...", "chat_enabled": true}
|
||||
◄─────────────────────────────────────┤
|
||||
│
|
||||
4. Подключение через LiveKit JS SDK
|
||||
├────────────────► LiveKit SFU
|
||||
│ │
|
||||
│ 5. Запись аудио per-track через Egress (при завершении сеанса)
|
||||
└ (video/audio stream) ──────────►
|
||||
```
|
||||
|
||||
БД: INSERT `conference_participants` (user_id ИЛИ guest_id, session_id, joined_at).
|
||||
|
||||
---
|
||||
|
||||
### 2. Конференция заканчивается → пост-обработка
|
||||
|
||||
```
|
||||
room_finished webhook
|
||||
│
|
||||
1. Backend помечает завершение записи, ожидает завершения egress
|
||||
│
|
||||
2. enqueue_pipeline(session_id) → Redis, очередь `transcription`
|
||||
│
|
||||
3. run_pipeline(session_id) — диспетчер пайплайна
|
||||
├─► Ожидание завершения egress (retry с backoff)
|
||||
├─► Для каждого трека:
|
||||
│ ├─► Загрузить плагин transcriber (FasterWhisperCPU/GPU или null)
|
||||
│ ├─► transcriber.transcribe(file_path, language='ru')
|
||||
│ └─► Сохранить segments в session_audio_tracks.segments (JSONB)
|
||||
├─► Собрать сегменты по участникам (с учётом смещений)
|
||||
├─► build_phrases(segments_by_participant, track_offsets)
|
||||
├─► DELETE phrases + INSERT новые → таблица phrases
|
||||
├─► UPDATE pipeline_status = 'summarizing'
|
||||
│
|
||||
4. send_task_with_retry ставит summarize_session → очередь `summarize`
|
||||
│
|
||||
5. summarize_session
|
||||
├─► Получить фразы сеанса, собрать транскрипт
|
||||
├─► Разбить на чанки (20 мин по умолчанию, зависит от уровня AI)
|
||||
├─► Qwen3.5 map-reduce (промпты в workers/summarizer/prompts/)
|
||||
├─► UPDATE conference_sessions.summary_data = '{...}' (pipeline_status остаётся 'summarizing')
|
||||
│
|
||||
6. send_task_with_retry ставит notify_session → очередь `notify`
|
||||
│
|
||||
7. notify_session
|
||||
├─► Собрать получателей (режим: all=участники+гости / owner=владелец)
|
||||
├─► Для каждого получателя: проверить email_deliveries, отправить письмо
|
||||
├─► Сгенерировать HTML + plaintext письмо (backend/services/email_templates.py)
|
||||
├─► Отправить через email-бэкенд (console|smtp, backend/services/email.py)
|
||||
├─► UPDATE email_deliveries (идемпотентность повторной отправки)
|
||||
├─► UPDATE pipeline_status = 'notified' (когда все получатели обработаны)
|
||||
│
|
||||
8. Beat-задачи recover_stuck_summaries/recover_stuck_notifications (интервал 300 сек)
|
||||
├─► Найти сеансы, зависшие в 'summarizing' (без summary_data или без уведомления)
|
||||
├─► Переставить соответствующую задачу в очередь для восстановления после сбоя брокера
|
||||
```
|
||||
|
||||
**Ключевые особенности:**
|
||||
- **Диспетчер единый:** `run_pipeline()` управляет всеми шагами транскрибации
|
||||
- **Per-track идемпотентность:** каждый трек коммитится отдельно (точка возобновления)
|
||||
- **Реконструкция фраз:** группировка по спикеру, короткие вставки, перекрытия речи (`workers/transcription/phrases.py`)
|
||||
- **Атрибуция гостям:** фразы и треки связаны с `conference_participants`, не `users` (ADR-002)
|
||||
- **Проверка pipeline_status:** guard идемпотентности (повторный запуск пропустит готовые шаги)
|
||||
|
||||
---
|
||||
|
||||
## Обработка времени
|
||||
|
||||
**Инвариант:** все времена в БД в UTC (`timestamptz`).
|
||||
|
||||
### Server-side (PostgreSQL)
|
||||
```sql
|
||||
INSERT INTO conferences (scheduled_at, duration_minutes)
|
||||
VALUES ('2026-07-15T14:30:00+00:00', 60);
|
||||
```
|
||||
|
||||
### Client-side (JavaScript)
|
||||
```typescript
|
||||
const startUtc = new Date('2026-07-15T14:30:00Z');
|
||||
// Браузер форматирует в локальный часовой пояс пользователя
|
||||
```
|
||||
|
||||
### .ics export
|
||||
```ics
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//VidConf//EN
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Moscow
|
||||
...
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
UID:...
|
||||
DTSTART;TZID=Europe/Moscow:20260715T143000
|
||||
DTEND;TZID=Europe/Moscow:20260715T153000
|
||||
...
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ключевые архитектурные решения
|
||||
|
||||
1. **Динамические конференции вместо бронирований** (ADR-001) — конференция как пользовательская сущность
|
||||
- Номер (9 цифр) + slug (base64url) — постоянные идентификаторы доступа
|
||||
- Статусы: scheduled/active/ended
|
||||
- Повторение: RecurrenceRule (JSON, 4 типа)
|
||||
- Гости — полноценные участники (`guest_access`)
|
||||
|
||||
2. **Номер и slug как идентификаторы доступа** (ADR-001, п.4) — публичные эндпоинты без auth
|
||||
- **Номер:** 9 цифр, первая 1..9 (энтропия ≈2^29.75); перебор при rate limit ≈60+ дней
|
||||
- **Slug:** base64url 8 байт → 11 символов (64 бита энтропии); имя LiveKit-комнаты
|
||||
- **Резолв:** единообразный 404 (живая/мёртвая неразличимы для безопасности)
|
||||
- **Rate limit:** 10 запросов в минуту на IP для `/resolve` и `/guest-join`
|
||||
|
||||
3. **Гости как полноценные участники** (ADR-001, п.6) — представление при входе
|
||||
- `guest_access`: display_name (обязателен), email (факультативен)
|
||||
- LiveKit identity: `guest:{guest_id}` (email не передаётся)
|
||||
- Участвуют в пайплайне саммари (email в рассылке, если заполнен)
|
||||
|
||||
4. **Recurrence как собственная модель, не RRULE** (ADR-001, п.3) — 4 типа, привязаны к форме UI
|
||||
- `type`: weekly | biweekly | monthly | every_n_days
|
||||
- Хранение в JSONB (`conferences.recurrence`), развёртка occurrences в памяти
|
||||
- Правило содержит локальное время + IANA-таймзону; развёртка — в UTC
|
||||
|
||||
5. **Паттерн Strategy для плагинов** — подключаемые AI-реализации без изменений ядра
|
||||
- Интерфейсы Transcriber/Summarizer в `backend/core/plugins/`
|
||||
- Паттерн Factory с реестром
|
||||
- Конфиг через YAML
|
||||
|
||||
6. **Идемпотентный pipeline пост-обработки** — безопасно повторять любой шаг
|
||||
- Каждый шаг проверяет `pipeline_status` перед продолжением
|
||||
- Per-track коммит сегментов (точка возобновления в БД)
|
||||
- Неудачные задачи можно повторить без дублирования (DELETE+INSERT фраз в одной транзакции)
|
||||
|
||||
7. **Атрибуция фраз и треков к участнику сеанса** (ADR-002) — поддержка гостей без user_id
|
||||
- `phrases.participant_id` → FK `conference_participants.id` (вместо user_id)
|
||||
- `session_audio_tracks.participant_id` → FK `conference_participants.id`
|
||||
- Гость без `user_id` проходит пайплайн наравне с пользователем
|
||||
|
||||
8. **Per-track аудио в SFU** — исключает необходимость диаризации спикеров
|
||||
- LiveKit предоставляет per-track recording (один трек = один микрофон = один спикер)
|
||||
- Track SID прямо соответствует identity участника
|
||||
- Запись через Track Egress в `.ogg` (opus)
|
||||
|
||||
9. **UUID первичные ключи** — поддержка распределённых систем и репликации
|
||||
- Сгенерировано через `gen_random_uuid()`
|
||||
- Исключения: таблицы высокой частоты (`phrases`, `chat_messages`) используют BIGINT Identity
|
||||
|
||||
10. **Настройки инстанса в БД** — бутстрап из `plugins.yaml`
|
||||
- Таблица `instance_settings` (key-value JSONB) импортирует дефолты `config/plugins.yaml` при старте backend
|
||||
- Однократно и идемпотентно (`INSERT ... ON CONFLICT DO NOTHING`)
|
||||
- Воркеры читают эффективную конфигурацию на старте каждой задачи
|
||||
- Административный интерфейс может менять настройки без рестарта
|
||||
|
||||
11. **Рассылка саммари с переопределением на уровне конференции** — гибкая конфигурация
|
||||
- `instance_settings.summary_recipients` — дефолт инстанса (`'all'` или `'owner'`)
|
||||
- `conferences.summary_recipients` — переопределение для конкретной конференции (nullable)
|
||||
- Эффективный режим = `conference.summary_recipients or cfg.summary_recipients`
|
||||
|
||||
12. **Идемпотентная рассылка саммари** — без дублирования писем
|
||||
- Таблица `email_deliveries` с уникальным частичным индексом `(session_id, recipient_email)` WHERE `kind='summary'`
|
||||
- Повторный запуск уведомления отправляет письмо только адресатам, которых ещё нет в таблице
|
||||
- Семантика at-least-once: редкий дубль письма возможен, потеря — нет
|
||||
- Per-получательный commit обеспечивает точку возобновления при обрыве
|
||||
|
||||
13. **Email-транспорт только через `.env`** — секреты никогда не попадают в БД или API
|
||||
- Переключатель бэкенда (`EMAIL_BACKEND=console|smtp`) — переменная окружения
|
||||
- Все SMTP-реквизиты (хост, порт, пароль, from) — только в `.env`
|
||||
- Секреты не логируются и не попадают в `SettingsOut` API
|
||||
- `aiosmtplib` с обработкой ошибок (retryable vs. skip)
|
||||
|
||||
14. **Управление командами и опциями регистрации** — справочник команд и гибкий вход
|
||||
- Таблица `teams` — справочник команд (id, name UNIQUE, created_at); админ-API CRUD
|
||||
- `users.team_id` (nullable) → FK `teams.id` с `ON DELETE SET NULL`
|
||||
- Настройка инстанса `registration_team_choice` (bool) — показ выбора команды при регистрации; `GET /auth/registration-options` возвращает список доступных команд
|
||||
- Настройка инстанса `registration_email_domain` (bool, domain: str|null) — обязательное совпадение домена email при регистрации (иначе 400 `invalid_email_domain`)
|
||||
|
||||
---
|
||||
|
||||
## Безопасность
|
||||
|
||||
- **Пароли:** хэширование Argon2 (никогда не логируется и не раскрывается)
|
||||
- **JWT access-токен:** HS256, TTL по умолчанию 15 минут (`ACCESS_TOKEN_TTL_MINUTES`), передаётся в заголовке `Authorization: Bearer`
|
||||
- **Refresh-токен:** httpOnly SameSite=Strict cookie, TTL по умолчанию 14 дней; отзывается при logout
|
||||
- **LiveKit токены:** ограничены по времени (TTL 6 часов), выдаются на конкретную комнату
|
||||
- **HTTPS/TLS:** терминируется на Nginx; dev-окружение использует HTTP
|
||||
- **CSRF:** митигируется `SameSite=Strict` на cookie с refresh-токеном (не отправляется с cross-site запросов); access-токен передаётся в заголовке, а не в cookie, и потому не подвержен CSRF
|
||||
- **SQL injection:** SQLAlchemy ORM с параметризованными запросами
|
||||
- **Rate limiting:** Redis-based, публичные эндпоинты (`/resolve`, `/guest-join`) — 10 запросов/мин на IP
|
||||
|
||||
---
|
||||
|
||||
## ADR (Записи архитектурных решений)
|
||||
|
||||
Подробные обоснования см. в папке [adr/](./adr/):
|
||||
- [001-dynamic-conferences-pivot.md](./adr/001-dynamic-conferences-pivot.md) — динамические конференции вместо бронирований
|
||||
- [002-phrase-attribution-session-participant.md](./adr/002-phrase-attribution-session-participant.md) — атрибуция фраз и треков к участнику сеанса
|
||||
- [003-conference-invitees.md](./adr/003-conference-invitees.md) — приглашённые на конференцию
|
||||
- [004-ai-tier-matrix.md](./adr/004-ai-tier-matrix.md) — матрица уровней AI
|
||||
- [005-password-reset-deferred.md](./adr/005-password-reset-deferred.md) — сброс пароля по email отложен
|
||||
- [000-template.md](./adr/000-template.md) — шаблон ADR
|
||||
|
||||
---
|
||||
|
||||
## Производительность и масштабируемость
|
||||
|
||||
### Уровни AI качества (ADR-004)
|
||||
|
||||
**Пресет 1–2 (без AI):**
|
||||
- 10–20 одновременных конференций
|
||||
- 4 vCPU, 8 GB RAM, 40 GB диск
|
||||
|
||||
**Пресет 3: AI min** (faster-whisper small + Qwen3.5-4B)
|
||||
- 10–20 одновременных конференций
|
||||
- 8 vCPU, 16 GB RAM, 100 GB диск
|
||||
- Транскрибация: ~2x realtime (10мин → 5мин на 8-ядерном CPU)
|
||||
|
||||
**Пресет 4: AI medium** (faster-whisper medium + Qwen3.5-9B; GPU опционально)
|
||||
- 15–30 одновременных конференций (CPU) или 30–50 (GPU)
|
||||
- 12–16 vCPU, 32 GB RAM, 150 GB диск
|
||||
- CPU: ~3x realtime; GPU (≥8 GB): ~1.5x realtime
|
||||
|
||||
**Пресет 5: AI max** (faster-whisper large-v3 + Qwen3.5-35B-A3B; GPU обязателен)
|
||||
- 50–100+ одновременных конференций
|
||||
- 16+ vCPU, 64 GB RAM, 250 GB диск, GPU ≥16 GB VRAM
|
||||
- Транскрибация: ~0.5x realtime (10мин → 20сек на V100)
|
||||
|
||||
См. [docs/deploy/hardware-profiles.md](../deploy/hardware-profiles.md) и [ADR-004](./adr/004-ai-tier-matrix.md).
|
||||
|
||||
---
|
||||
|
||||
## Развёртывание
|
||||
|
||||
### Установка
|
||||
|
||||
```bash
|
||||
./install.sh # интерактивный опросник (автодетект железа)
|
||||
./install.sh --preset 3 # неинтерактивно (пресет 3 = AI min)
|
||||
```
|
||||
|
||||
Инсталлятор автоматически:
|
||||
1. Детектирует CPU/RAM/GPU
|
||||
2. Рекомендует пресет
|
||||
3. Генерирует `.env` с секретами
|
||||
4. Запускает `docker compose` с нужными профилями
|
||||
5. Выполняет миграции и seed
|
||||
|
||||
### Локальная разработка (вручную)
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml up -d # пресет 1 (без AI)
|
||||
docker compose -f deploy/docker-compose.yml \
|
||||
--profile media --profile transcribe --profile llm up -d # пресет 3 (с AI min)
|
||||
```
|
||||
|
||||
### Продакшен
|
||||
- Docker Compose на одном хосте (текущий целевой сценарий)
|
||||
- Nginx для TLS + статические файлы
|
||||
- Переменные окружения для секретов (`.env`)
|
||||
- Резервные копии БД (PostgreSQL dump)
|
||||
- Мониторинг (Prometheus + Grafana, `--profile monitoring`)
|
||||
|
||||
См. [docs/deploy/dev-setup.md](../deploy/dev-setup.md).
|
||||
|
||||
---
|
||||
|
||||
## Мониторинг и наблюдаемость
|
||||
|
||||
- **Логи:** Docker-логи + логирование приложений
|
||||
- **Метрики:** `GET /metrics` — `vidconf_http_request_duration_seconds`, `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth` (см. [workers/README.md](../../workers/README.md#метрики))
|
||||
- **Визуализация:** Grafana (compose-профиль `monitoring`)
|
||||
- **БД:** журнал медленных запросов PostgreSQL
|
||||
|
||||
См. [docs/deploy/monitoring.md](../deploy/monitoring.md).
|
||||
|
||||
---
|
||||
|
||||
## Стратегия тестирования
|
||||
|
||||
- **Backend:** pytest (unit + интеграционные тесты)
|
||||
- **Frontend:** ESLint + TypeScript (`tsc -b`); автоматических unit/E2E тестов пока нет
|
||||
- **БД:** проверка версии Alembic (`alembic current`), автогенерация миграций
|
||||
|
||||
---
|
||||
|
||||
## Ссылки
|
||||
|
||||
- [Корневой README](../../README.md)
|
||||
- [Схема БД](../db/schema.md)
|
||||
- [Справка API](../api/README.md)
|
||||
- [Архитектура плагинов](../plugins/contracts.md)
|
||||
- [Профили развёртывания](../deploy/hardware-profiles.md)
|
||||
- [Backend README](../../backend/README.md)
|
||||
- [Frontend README](../../frontend/README.md)
|
||||
- [Workers README](../../workers/README.md)
|
||||
0
docs/architecture/adr/.gitkeep
Normal file
0
docs/architecture/adr/.gitkeep
Normal file
54
docs/architecture/adr/000-template.md
Normal file
54
docs/architecture/adr/000-template.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Шаблон ADR
|
||||
|
||||
## Заголовок
|
||||
[Краткое название архитектурного решения]
|
||||
|
||||
## Статус
|
||||
[PROPOSED | ACCEPTED | DEPRECATED | SUPERSEDED]
|
||||
|
||||
## Контекст
|
||||
Опишите проблему, которая мотивирует это решение. Укажите значимые факты:
|
||||
- Почему это решение нужно?
|
||||
- Какие ограничения или требования применимы?
|
||||
- Какие альтернативы рассматривались?
|
||||
|
||||
## Решение
|
||||
Сформулируйте принятое решение чётко и кратко.
|
||||
|
||||
## Последствия
|
||||
Опишите результаты и следствия этого решения:
|
||||
- **Плюсы:** выгоды, улучшения
|
||||
- **Минусы:** компромиссы, риски
|
||||
- **Нейтрально:** изменения, которые не хороши и не плохи
|
||||
|
||||
## Ссылки
|
||||
- Связанные ADR (если есть)
|
||||
- Внешняя документация или стандарты
|
||||
- Файлы кода, реализующие это решение
|
||||
|
||||
---
|
||||
|
||||
## Пример: ADR-001 Использование UUID как первичного ключа
|
||||
|
||||
### Статус
|
||||
ACCEPTED
|
||||
|
||||
### Контекст
|
||||
VidConf требует глобально уникальных идентификаторов для распределённых операций и будущего шардирования.
|
||||
- Генерация UUID в PostgreSQL быстрая (через `gen_random_uuid()`)
|
||||
- Не требует центральной нумерации
|
||||
- Поддерживает репликацию без координации
|
||||
|
||||
### Решение
|
||||
Все таблицы используют `UUID` (версия 4) как первичный ключ, генерируемый на сервере через `gen_random_uuid()`.
|
||||
Исключения: `phrases` и `chat_messages` используют `BIGINT IDENTITY` для высокочастотных вставок.
|
||||
|
||||
### Последствия
|
||||
- **Плюс:** уникальность на всех инстансах; не требует глобальной координации
|
||||
- **Плюс:** поддерживает будущие распределённые архитектуры
|
||||
- **Минус:** больший размер индекса (16 байт против 8 у BIGINT)
|
||||
- **Нейтрально:** требует явной поддержки типа UUID в ORM
|
||||
|
||||
### Ссылки
|
||||
- `backend/models/*.py` — все модели используют `Mapped[uuid.UUID]`
|
||||
- `backend/alembic/versions/1e2e34a0cb06_initial_schema.py` — миграция
|
||||
161
docs/architecture/adr/001-dynamic-conferences-pivot.md
Normal file
161
docs/architecture/adr/001-dynamic-conferences-pivot.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# ADR-001: Динамические конференции вместо бронирований комнат
|
||||
|
||||
## Статус
|
||||
ACCEPTED
|
||||
|
||||
## Контекст
|
||||
Продукту не подходит модель предустановленных переговорных комнат с
|
||||
бронированием: конференция должна создаваться динамически (мгновенно из
|
||||
лобби или планово из календаря). Незакреплённая умирает по завершении
|
||||
(история/саммари остаются), закреплённая — постоянная, с повторениями. Вход
|
||||
— по ссылке или номеру, гости допускаются после «представиться». Прежняя
|
||||
схема (`rooms` + `room_bookings` с EXCLUDE-constraint, `conferences` как
|
||||
сеанс, привязанный к `room_id`) этой концепции не соответствует.
|
||||
Продакшен-данных на момент миграции не было — допустима структурная миграция
|
||||
с переименованием таблиц.
|
||||
|
||||
## Решения
|
||||
|
||||
### 1. Модель данных
|
||||
Двухуровневая модель: **конференция** (пользовательская сущность) и **сеанс**
|
||||
(один запуск конференции, единица AI-пайплайна).
|
||||
|
||||
- Таблица `conferences` **переименовывается** в `conference_sessions`
|
||||
(данные сохраняются): `id`, `conference_id` FK→conferences (NOT NULL, CASCADE),
|
||||
`title` (снапшот), `t_start`, `t_end`, `pipeline_status`, `summary_data`,
|
||||
`created_at`. Колонки `room_id`, `booking_id` удаляются.
|
||||
Статус-машина пост-обработки живёт в `conference_sessions.pipeline_status`
|
||||
(семантика не меняется).
|
||||
- Создаётся **новая** таблица `conferences` — сущность конференции:
|
||||
`id UUID PK`, `number VARCHAR(9) UNIQUE NOT NULL`, `slug VARCHAR(22) UNIQUE NOT NULL`,
|
||||
`title VARCHAR(255) NULL`, `owner_id UUID NULL FK users ON DELETE SET NULL`,
|
||||
`status conference_status NOT NULL DEFAULT 'scheduled'`,
|
||||
`is_pinned BOOL NOT NULL DEFAULT false`, `is_closed BOOL NOT NULL DEFAULT false`,
|
||||
`password_hash TEXT NULL`, `scheduled_at TIMESTAMPTZ NULL`,
|
||||
`duration_minutes INT NULL`, `recurrence JSONB NULL`,
|
||||
`ended_at TIMESTAMPTZ NULL`, `created_at`.
|
||||
CHECK: `is_closed = false OR password_hash IS NOT NULL`;
|
||||
`recurrence IS NULL OR is_pinned = true`.
|
||||
- В `phrases`, `chat_messages`, `conference_participants` колонка
|
||||
`conference_id` переименовывается в `session_id` (FK → conference_sessions).
|
||||
- `rooms`, `room_bookings`, `booking_participants` **удаляются**. Backfill в
|
||||
миграции: для каждой room, на которую ссылаются сеансы, создаётся запись
|
||||
conferences (status='ended', slug=permanent_link, номер генерируется,
|
||||
owner_id=NULL), сеансы перевязываются, затем таблицы комнат/броней дропаются.
|
||||
Список допущенных участников закрытой брони (`booking_participants`) уходит
|
||||
без замены: доступ к закрытой конференции — только по паролю (утверждённая
|
||||
концепция, п. 7).
|
||||
|
||||
### 2. Жизненный цикл
|
||||
`conference_status` = ENUM(`scheduled`, `active`, `ended`).
|
||||
- Статус `draft` отклонён: создание атомарно из формы, черновики не нужны.
|
||||
- «pinned» — не статус, а ортогональный флаг `is_pinned` (закреплённость не
|
||||
исключает ни scheduled, ни active).
|
||||
- Переходы: мгновенное создание → `active` (вход сразу); плановое → `scheduled`;
|
||||
webhook `room_started` → `active`; `room_finished` → `ended` (если не
|
||||
закреплена; ставится `ended_at`) или обратно `scheduled` (закреплена).
|
||||
Beat-задача переводит в `ended` незакреплённые scheduled, чьё время истекло
|
||||
без единого сеанса. `ended` — терминальный: join отвечает 410, строка и
|
||||
история не удаляются.
|
||||
|
||||
### 3. Recurrence — собственная модель, не RRULE
|
||||
Хранится в `conferences.recurrence` (JSONB), Pydantic-схема `RecurrenceRule`:
|
||||
|
||||
```
|
||||
type: 'weekly' | 'biweekly' | 'monthly' | 'every_n_days'
|
||||
weekdays: list[int] # 0=пн…6=вс — для weekly/biweekly
|
||||
day_of_month: int (1..31) # для monthly; 31 в коротком месяце → последний день
|
||||
interval_days: int >= 1 # для every_n_days
|
||||
anchor_date: date # точка отсчёта чётности biweekly / шага every_n_days
|
||||
time_local: 'HH:MM'
|
||||
timezone: str # IANA
|
||||
duration_minutes: int
|
||||
```
|
||||
|
||||
Обоснование: UI фиксирует ровно 4 типа повторения — структурированная модель
|
||||
отображается на форму 1:1, валидируется Pydantic и разворачивается чистой
|
||||
функцией `expand_occurrences(rule, t_from, t_to) -> list[datetime UTC]` (TDD);
|
||||
RRULE дал бы избыточную выразительность, парсинг и зависимость без выгоды.
|
||||
Инвариант №1 не нарушен: правило — не timestamp (локальное время + IANA-зона
|
||||
нужны для корректности при смене смещения), все timestamp-колонки — UTC.
|
||||
|
||||
### 4. Номер, постоянная ссылка и резолв
|
||||
- **Номер**: 9 десятичных цифр, первая 1–9 (`secrets.randbelow`), уникален,
|
||||
генерация с retry при коллизии. Энтропия: 9·10^8 вариантов ≈ 2^29.75.
|
||||
Оценка перебора: при ≤1000 живых конференций вероятность угадать с одной
|
||||
попытки ≤ 1.2·10^-6; при rate limit 10 запросов/мин на IP матожидание
|
||||
подбора с одного IP ≈ 60+ суток непрерывного перебора. Отображение —
|
||||
группами 3-3-3 («884 210 466»); в макетах номера-плейсхолдеры 7-значные —
|
||||
это контент, не layout, отклонение фиксируется здесь.
|
||||
- **Резолв** (`GET /conferences/resolve`, публичный, rate limit):
|
||||
- несуществующий номер/slug → **404** (единообразный, без деталей);
|
||||
- существующая завершённая (`ended`) → **200 с минимальным ответом
|
||||
`{id, title, status='ended'}`** — пользователь по старой ссылке/номеру
|
||||
видит «конференция завершена», а не «не найдено»;
|
||||
- для `ended` НЕ раскрывается ничего сверх минимума: `is_closed` /
|
||||
`requires_password` не возвращаются (войти всё равно нельзя).
|
||||
Trade-off принят осознанно: утечка факта существования/названия завершённой
|
||||
конференции допустима, т.к. держатель slug (64 бита) или номера практически
|
||||
всегда — бывший участник, перебор закрыт энтропией и rate limit'ом, а
|
||||
реальный барьер повторного входа — **410 на join/guest-join** (протестировано).
|
||||
- **Ссылка**: `slug = secrets.token_urlsafe(8)` — 11 символов base64url,
|
||||
64 бита энтропии; URL вида `/j/{slug}`. Slug также служит именем
|
||||
LiveKit-комнаты (замена room.permanent_link). Номер и slug неизменны всё
|
||||
время жизни конференции и не переиспользуются.
|
||||
|
||||
### 5. Судьба инварианта №2 (EXCLUDE USING gist)
|
||||
Constraint **снимается** — исчезает вместе с таблицей `room_bookings`.
|
||||
Конференции не конкурируют за общий ресурс: пересечения по времени у одного
|
||||
владельца допустимы by design, защита БД не нужна. Расширение `btree_gist`
|
||||
из БД не удаляем (безвредно, миграция проще и обратима).
|
||||
|
||||
### 6. Гости
|
||||
- Новая таблица `guest_access`: `id UUID PK`, `conference_id` FK→conferences
|
||||
(CASCADE), `display_name VARCHAR(255) NOT NULL`, `email VARCHAR(320) NULL`,
|
||||
`created_at`. Создаётся эндпоинтом гостевого join (без auth, rate limit).
|
||||
- LiveKit identity: зарегистрированный — `str(user_id)` (как сейчас, обратная
|
||||
совместимость webhook-парсера); гость — `guest:{guest_access.id}`,
|
||||
`name=display_name`. Email в LiveKit (metadata) не передаётся — PII не
|
||||
утекает другим участникам.
|
||||
- `conference_participants`: `user_id` становится NULLABLE, добавляется
|
||||
`guest_id UUID NULL FK guest_access`; CHECK — заполнено ровно одно из двух.
|
||||
Webhook `participant_joined` по префиксу identity создаёт строку участника
|
||||
с user_id либо guest_id.
|
||||
- Рассылка саммари: получатели сеанса = email пользователей ∪
|
||||
`guest_access.email IS NOT NULL` участников сеанса.
|
||||
- Закрытая конференция требует пароль и от гостя.
|
||||
|
||||
### 7. Переиспользование кода бронирований / удаление
|
||||
Переиспользуется: инфраструктура FullCalendar и диалогов календаря
|
||||
(Booking* → Conference*), механика пароля (argon2, `JoinPasswordDialog`,
|
||||
`ClosedJoinPage` → единый join-flow), UTC-валидаторы из `schemas/bookings.py`,
|
||||
генерация slug (`token_urlsafe`), webhook-пайплайн с идемпотентностью,
|
||||
beat-каркас `release_idle_rooms` (адаптируется в очистку конференций),
|
||||
booking-модель концептуально → плановая конференция (`scheduled_at`,
|
||||
`is_closed`, `password_hash` переезжают в conferences).
|
||||
|
||||
Удаляется: seed 100 комнат (`backend/scripts/seed.py`), модели
|
||||
`room.py`/`booking.py`/`booking_participant.py`, сервисы
|
||||
`booking_rules.py`/`bookings.py`/`room_access.py` (включая «правило часа» —
|
||||
не имеет смысла без конкуренции за комнаты), репозитории `rooms.py`/`bookings.py`,
|
||||
роутеры `api/rooms.py`/`api/bookings.py`, схемы `rooms.py`/`bookings.py`,
|
||||
frontend: `RoomCard`, `lib/roomColors.ts`, `api/rooms.ts`, `api/bookings.ts`,
|
||||
Booking*-диалоги, тесты бронирования/комнат.
|
||||
|
||||
## Последствия
|
||||
- **Плюсы:** модель 1:1 соответствует продукту; исчезает класс конфликтов
|
||||
бронирования и его код; гости — полноценные участники пайплайна саммари;
|
||||
единый join-flow (ссылка/номер/пароль/гость); внятный UX по старым
|
||||
ссылкам («конференция завершена» вместо «не найдено»).
|
||||
- **Минусы:** разрушительная миграция (переименование таблиц/колонок) —
|
||||
допустимо до продакшена, но затрагивает пайплайн пост-обработки (пишет в
|
||||
`conference_sessions`); публичные эндпоинты resolve/guest-join требуют
|
||||
rate limiting (Redis) и единообразного 404 для несуществующих; резолв
|
||||
раскрывает существование и название завершённой конференции держателю её
|
||||
номера/ссылки (принятый trade-off, см. п. 4).
|
||||
- **Нейтрально:** `btree_gist` остаётся установленным без использования.
|
||||
|
||||
## Ссылки
|
||||
- `design/mockups/{lobby,join,calendar,my-conferences}.html` — утверждённый UI
|
||||
- `backend/alembic/versions/f418dd65e7b1_dynamic_conferences.py` — миграция реализует раздел «Модель данных»
|
||||
- `backend/api/conferences.py` — резолв/join/guest-join по п. 4 и п. 6
|
||||
@@ -0,0 +1,42 @@
|
||||
# ADR-002. Атрибуция аудиотреков и фраз к участнику сеанса (participant_id вместо user_id)
|
||||
|
||||
## Статус
|
||||
ПРИНЯТО
|
||||
|
||||
## Контекст
|
||||
FR-4.2 ТЗ фиксирует схему `phrases (id, user_id, conferences_id, data, t_start,
|
||||
t_end)` — атрибуция фразы к зарегистрированному пользователю. После перехода
|
||||
на динамические конференции (ADR-001) среди участников сеанса есть ГОСТИ без `user_id`
|
||||
(`conference_participants` допускает ровно одну identity: `user_id` ИЛИ
|
||||
`guest_id`). Кроме того, для записи per-track аудио (LiveKit Track Egress)
|
||||
нужен персистентный маппинг «файл записи ↔ участник сеанса», которого в схеме
|
||||
нет. Альтернативы:
|
||||
|
||||
- пара nullable-колонок `user_id`/`guest_id` в `phrases` — дублирует
|
||||
CHECK-логику `conference_participants` в каждой таблице пайплайна;
|
||||
- заводить фиктивного user для гостя — нарушает модель auth и FR-1.
|
||||
|
||||
## Решение
|
||||
1. В `phrases` колонка `user_id` заменяется на `participant_id` —
|
||||
NOT NULL FK на `conference_participants.id` (ON DELETE CASCADE). Спикер
|
||||
фразы — всегда строка участника сеанса; имя/email для отображения и
|
||||
рассылки берутся join'ом через `user_id`/`guest_id` участника.
|
||||
2. Вводится таблица `session_audio_tracks`: одна строка на audio-трек сеанса
|
||||
(track SID, egress ID, путь к файлу, статус, `started_at`,
|
||||
`segments` JSONB) с тем же FK `participant_id`. Она — источник маппинга
|
||||
«файл ↔ спикер» и точка идемпотентного возобновления транскрибации.
|
||||
|
||||
## Последствия
|
||||
- **Плюсы:** гости атрибутируются без костылей; единая точка истины об
|
||||
identity (`conference_participants`); повторное подключение того же
|
||||
пользователя даёт разные строки участника — тайм-окна присутствия точны.
|
||||
- **Минусы:** выборка фраз «по пользователю» требует join через
|
||||
`conference_participants`; отступление от буквы FR-4.2 (фиксируется этим ADR).
|
||||
- **Нейтрально:** `segments` JSONB — промежуточный артефакт пайплайна,
|
||||
очищается не обязательно (объём мал: текст+тайминги).
|
||||
|
||||
## Ссылки
|
||||
- ADR-001 (динамические конференции, гостевой доступ).
|
||||
- `backend/models/phrase.py`, `backend/models/participant.py`,
|
||||
`backend/models/audio_track.py`.
|
||||
- Сеанс (`conference_sessions`) — единица пайплайна пост-обработки.
|
||||
70
docs/architecture/adr/003-conference-invitees.md
Normal file
70
docs/architecture/adr/003-conference-invitees.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# ADR-003. Модель приглашённых участников конференции (conference_invitees)
|
||||
|
||||
Статус: принято.
|
||||
|
||||
## Контекст
|
||||
|
||||
Продукту нужен состав приглашённых участников конференции: зарегистрированные
|
||||
пользователи (user_id) и внешние по произвольному email. Организатор обязан
|
||||
всегда быть в составе и быть неудаляемым. Уже существует таблица
|
||||
`conference_participants` — это ФАКТИЧЕСКИЕ участники сеанса (кто реально был,
|
||||
окна присутствия, единица атрибуции фраз, ADR-002); смешивать сущности нельзя.
|
||||
|
||||
## Решение
|
||||
|
||||
1. Новая таблица `conference_invitees` — приглашённые НА КОНФЕРЕНЦИЮ
|
||||
(не на сеанс):
|
||||
- `id UUID PK`, `conference_id FK conferences ON DELETE CASCADE NOT NULL`;
|
||||
- `user_id FK users ON DELETE CASCADE NULL` — зарегистрированный;
|
||||
- `email VARCHAR(255) NULL` — внешний (хранится в lower-case);
|
||||
- `CHECK ((user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1)` —
|
||||
ровно одна identity (тот же приём, что в `conference_participants`);
|
||||
- частичные UNIQUE: `(conference_id, user_id) WHERE user_id IS NOT NULL`
|
||||
и `(conference_id, email) WHERE email IS NOT NULL` — без дублей;
|
||||
- `created_at`.
|
||||
2. Организатор в таблице НЕ хранится: он выводится из `conferences.owner_id`
|
||||
и всегда добавляется в состав на уровне API/рассылки. Инвариант
|
||||
«организатор всегда в составе и неудаляем» обеспечен конструктивно —
|
||||
удалить его из состава невозможно в принципе, рассинхронизация при смене
|
||||
владельца исключена. Попытка добавить владельца в invitees (по user_id или
|
||||
его email) молча дедуплицируется на записи.
|
||||
3. Состав задаётся списком целиком (PUT-семантика поля `participants` в
|
||||
create/update конференции): backend вычисляет diff, отсутствие поля —
|
||||
«не менять». Права на изменение состава = права на изменение конференции.
|
||||
4. Связь с фактическими участниками сеанса — аналитическая, по join без FK:
|
||||
зарегистрированный — `conference_participants.user_id = invitees.user_id`;
|
||||
внешний — `lower(guest_access.email) = invitees.email` (если приглашённый
|
||||
вошёл гостем и указал тот же email). FK не вводим: гость может войти
|
||||
с другим email или не войти вовсе — жёсткая связь ложна по природе данных.
|
||||
5. Рассылка приглашений (.ics METHOD:REQUEST): получатели =
|
||||
организатор + invitees (email пользователя или внешний email); для
|
||||
закреплённых по-прежнему добавляются участники прошлых сеансов
|
||||
(`workers/tasks/invitations.py`, дедуп по lower(email)).
|
||||
6. **Видимость приглашённого в списках.** Приглашённый видит конференцию в
|
||||
`GET /conferences/my` и `GET /conferences/calendar` наравне с владельцем —
|
||||
строка попадает в выборку, если `owner_id == user.id` ИЛИ существует
|
||||
`conference_invitees` этой конференции с `user_id == user.id` ИЛИ с
|
||||
`lower(email) == lower(email пользователя)` (внешнее приглашение на адрес,
|
||||
под которым человек впоследствии зарегистрировался). Критерии показа
|
||||
(закреплённая — безусловно; разовая — `status=scheduled` и `scheduled_at`
|
||||
в будущем) не меняются, только круг «чей» конференция. `GET /conferences/{id}`
|
||||
аналогично открыт приглашённому (иначе ховер-карточка/детальная страница
|
||||
получали бы 403); `is_owner` в ответе для приглашённого — `false`,
|
||||
`organizer_name` — имя фактического владельца. Права на PATCH/DELETE это
|
||||
расширение НЕ затрагивает — по-прежнему только владелец/администратор.
|
||||
|
||||
## Последствия
|
||||
|
||||
- (+) Чистое разделение «приглашён» / «фактически был»; пайплайн атрибуции
|
||||
фраз (ADR-002) не затронут.
|
||||
- (+) Инвариант организатора не требует триггеров и проверок целостности.
|
||||
- (−) Внешний приглашённый не связывается с гостевым входом надёжно (только
|
||||
эвристика по email) — принято как ограничение модели.
|
||||
- (−) Списки состава в ответах API требуют дозагрузки (`selectinload`) —
|
||||
следить за N+1 в `/my` и `/calendar`; показ приглашённому (п. 6) добавляет
|
||||
туда же `EXISTS`-подзапрос по `conference_invitees` и точечный запрос имени
|
||||
реального владельца на каждую НЕ свою строку списка — список короткий
|
||||
(закреплённые + предстоящие), нагрузка признана приемлемой.
|
||||
- Календарь и «Мои конференции» — выборка «владелец ИЛИ приглашённый» (п. 6);
|
||||
до 2026-07-20 показывались только конференции владельца — приглашённый
|
||||
видел состав лишь через уведомление/.ics, не через списки приложения.
|
||||
114
docs/architecture/adr/004-ai-tier-matrix.md
Normal file
114
docs/architecture/adr/004-ai-tier-matrix.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# ADR-004. Матрица уровней AI (min/medium/max): модели, кванты, железо, параметры генерации
|
||||
|
||||
## Статус
|
||||
ACCEPTED
|
||||
|
||||
## Контекст
|
||||
Продукту нужны три уровня качества AI-обработки (`min`/`medium`/`max`,
|
||||
`AiLevel` в `backend/core/plugins/config.py`) для пресетов инсталлятора 3–5.
|
||||
Ограничения: только локальные модели на всех уровнях (без внешних API);
|
||||
промпты `workers/summarizer/prompts/` едины и не меняются между уровнями —
|
||||
качество наращивается размером модели, а не правкой промптов. Ранний опыт с
|
||||
Qwen ~3B показал, что модель на пределе инструктивной сложности: reduce
|
||||
упирался в `max_tokens=1024`, отсюда per-tier лимиты (reduce ≥1536); часовой
|
||||
транскрипт на CPU ≈ 6,5 мин — ориентир для уровня «min».
|
||||
|
||||
Актуальное на момент решения поколение моделей — **Qwen3.5**: dense
|
||||
0.8B/2B/4B/9B («Small», thinking ВЫКЛЮЧЕН по умолчанию), dense 27B и MoE
|
||||
35B-A3B (мультимодальные, thinking ВКЛЮЧЁН по умолчанию, отключается
|
||||
`chat_template_kwargs: {"enable_thinking": false}`), крупнее — 122B-A10B,
|
||||
397B-A17B. Инференс поддержан llama.cpp (llama-server,
|
||||
`--chat-template-kwargs`), GGUF-кванты публикуются Qwen и Unsloth.
|
||||
Кандидаты Qwen3-4B/8B/14B/32B (предыдущее поколение) отклонены в пользу
|
||||
более нового поколения при том же рантайме.
|
||||
|
||||
faster-whisper: GPU через CTranslate2 — `WhisperModel(..., device="cuda",
|
||||
compute_type="float16")` (вариант `int8_float16` для экономии VRAM); нужны
|
||||
cuBLAS/cuDNN 9 для CUDA 12 (`pip install nvidia-cublas-cu12
|
||||
nvidia-cudnn-cu12==9.*` + `LD_LIBRARY_PATH`) и nvidia-container-toolkit.
|
||||
llama.cpp: официальные CUDA-образы `ghcr.io/ggml-org/llama.cpp:server-cuda`
|
||||
(CUDA 12) / `server-cuda13`; offload — `--n-gpu-layers` /
|
||||
`LLAMA_ARG_N_GPU_LAYERS`.
|
||||
|
||||
## Решение
|
||||
|
||||
### Матрица уровней
|
||||
|
||||
| Уровень | Транскрибация | Суммаризация (LLM) | Режим |
|
||||
|---|---|---|---|
|
||||
| **min** | faster-whisper `small`, CPU, `int8` (~0,5 ГБ весов) | **Qwen3.5-4B**, GGUF Q4_K_M ≈ 2,5–2,8 ГБ, llama.cpp CPU | thinking выключен по умолчанию (семейство Small) |
|
||||
| **medium** | faster-whisper `medium` (~1,5 ГБ): CPU `int8`; при GPU — `cuda`/`float16` (VRAM ~2–3 ГБ) | **Qwen3.5-9B**, GGUF Q4_K_M ≈ 6,2 ГиБ, llama.cpp CPU или GPU (полный offload от ~8 ГБ VRAM) | thinking выключен по умолчанию |
|
||||
| **max** | faster-whisper `large-v3` (~3 ГБ), только GPU, `cuda`/`float16` (VRAM ~4,5–5 ГБ) | **Qwen3.5-35B-A3B** (MoE, ~3B активных), GGUF Q4_K_M ≈ 20–22 ГБ, llama.cpp GPU (полный offload от ~24 ГБ VRAM; допустим гибрид GPU+RAM за счёт скорости) | thinking ПРИНУДИТЕЛЬНО отключается: `LLAMA_ARG_CHAT_TEMPLATE_KWARGS='{"enable_thinking":false}'` на llama-server |
|
||||
|
||||
Замена более раннего варианта (Qwen2.5-3B → Qwen3.5-4B на min) — сопоставимый
|
||||
размер/скорость, новее поколение, лучшее следование инструкциям; промпты
|
||||
не трогаем — они едины для всех уровней. Точные имена GGUF-файлов фиксируются в
|
||||
`deploy/llm/download-model.sh` при реализации (репозитории `Qwen/…-GGUF` /
|
||||
`unsloth/…-GGUF`); размеры выше — ориентиры для инсталлятора.
|
||||
|
||||
### Per-tier параметры генерации (промпты неизменны)
|
||||
|
||||
| Параметр | min | medium | max |
|
||||
|---|---|---|---|
|
||||
| temperature | 0.2 | 0.2 | 0.2 |
|
||||
| max_tokens (map) | 1024 | 1024 | 1536 |
|
||||
| max_tokens (reduce) | 1536 | 2048 | 2560 |
|
||||
| CTX llama-server | 16384 | 16384 | 16384 |
|
||||
|
||||
temperature 0.2 — осознанное отступление от рекомендаций карточки модели
|
||||
(0.7–1.0 для чата): суммаризация экстрактивная, нужна детерминированность.
|
||||
Раздельные лимиты map/reduce требуют параметров
|
||||
`max_tokens_map`/`max_tokens_reduce` в плагине `QwenLocal` (options, контракт
|
||||
`Summarizer` не меняется).
|
||||
|
||||
### Требования железа (таблица инсталлятора и детекта админки)
|
||||
|
||||
| Пресет | CPU | RAM | GPU (VRAM) | Диск | Модели на диске |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 MVP / 2 +чат | 4 vCPU | 8 ГБ | — | 40 ГБ | — |
|
||||
| 3 +AI min | 8 vCPU | 16 ГБ | — | 100 ГБ | ~3,5 ГБ |
|
||||
| 4 +AI medium | 12–16 vCPU | 32 ГБ | опционально ≥8 ГБ (ускорение) | 150 ГБ | ~8 ГБ |
|
||||
| 5 +AI max | 16+ vCPU | 64 ГБ | ОБЯЗАТЕЛЬНО NVIDIA ≥16 ГБ (рекоменд. 24 ГБ) | 250 ГБ | ~25 ГБ |
|
||||
|
||||
Детект: железо определяет `install.sh` (nproc, free, nvidia-smi) и пишет в
|
||||
`.env` (`HW_CPUS`, `HW_RAM_MB`, `HW_GPU_NAME`, `HW_VRAM_MB`); backend-детект
|
||||
доступности уровней (`services/ai_levels.py`) читает эти переменные плюс
|
||||
факт наличия скачанных моделей на томах — без зависимости от torch/nvidia-smi
|
||||
внутри контейнера.
|
||||
|
||||
## Последствия
|
||||
- **Плюс:** переключение уровней — только конфиг/админка; ядро и промпты
|
||||
неизменны; min остаётся CPU-only на всех уровнях.
|
||||
- **Плюс:** thinking-режим гарантированно выключен на всех уровнях
|
||||
(Small — по умолчанию, MoE — флагом сервера), формат вывода промптов
|
||||
сохраняется.
|
||||
- **Минус:** Qwen3.5 требует свежий llama.cpp — тег образа
|
||||
`ghcr.io/ggml-org/llama.cpp:server[-cuda]` фиксируется по digest в compose;
|
||||
риск несовместимости старых GGUF (арх. `qwen35`) закрывается скачиванием
|
||||
только официальных квантов.
|
||||
- **Минус:** GPU-стек (nvidia-container-toolkit, cuDNN 9) — новая
|
||||
эксплуатационная зависимость пресетов 4 (опция) и 5 (обязательно).
|
||||
- **Нейтрально:** 27B dense отклонён для max в пользу MoE 35B-A3B: при
|
||||
сравнимом качестве ~3B активных параметров дают кратно большую скорость
|
||||
на том же VRAM-бюджете.
|
||||
|
||||
## Аддендум
|
||||
|
||||
Флаг `LLAMA_ARG_CHAT_TEMPLATE_KWARGS='{"enable_thinking":false}'`, названный
|
||||
выше для принудительного отключения thinking на уровне `max`, в актуальной
|
||||
llama.cpp имеет более простой равнозначный эквивалент: `LLAMA_ARG_REASONING=off`
|
||||
(`--reasoning off`) — по `common/arg.cpp` проекта llama.cpp флаг выставляет
|
||||
`enable_thinking=false` в шаблоне чата сервера тем же эффектом, без
|
||||
необходимости передавать сырой JSON `chat_template_kwargs` через переменную
|
||||
окружения. Реализация (`deploy/docker-compose.yml`) использует
|
||||
`LLAMA_ARG_REASONING=off`; сама матрица уровней и решение (thinking отключён на
|
||||
`max`) не меняются.
|
||||
|
||||
## Ссылки
|
||||
- ADR-001 (динамические конференции).
|
||||
- `backend/core/plugins/{faster_whisper,qwen_local}.py`,
|
||||
`backend/services/ai_levels.py`, `config/plugins.yaml` — реализация.
|
||||
- unsloth.ai/docs/models/qwen3.5 (линейка, режимы, требования памяти),
|
||||
huggingface.co/Qwen/Qwen3.5-35B-A3B (enable_thinking, Q4_K_M 9B = 6,22 ГиБ),
|
||||
github.com/SYSTRAN/faster-whisper (CUDA/CTranslate2),
|
||||
github.com/ggml-org/llama.cpp docs/docker.md (server-cuda).
|
||||
37
docs/architecture/adr/005-password-reset-deferred.md
Normal file
37
docs/architecture/adr/005-password-reset-deferred.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# ADR-005: Сброс пароля по email отложен до v0.1.0
|
||||
|
||||
Статус: принято.
|
||||
|
||||
## Контекст
|
||||
Ссылка «Забыли пароль?» на странице входа (сброс пароля по email-токену,
|
||||
аналогично подтверждению регистрации, со страницей задания нового пароля)
|
||||
рассматривалась для релиза v0.0.1. Инфраструктура писем есть
|
||||
(`services/email.py`, верификация регистрации), но полный флоу сброса
|
||||
требует: новый тип одноразового токена и его хранение/инвалидацию, публичный
|
||||
эндпоинт запроса сброса с rate limit и единообразным ответом (защита от
|
||||
перебора email), эндпоинт применения токена, новую страницу фронтенда, отзыв
|
||||
активных refresh-сессий, тесты на всё перечисленное. Это заметный
|
||||
security-чувствительный объём непосредственно перед тегом v0.0.1.
|
||||
|
||||
## Решение
|
||||
1. В релиз v0.0.1 входит только смена пароля в профиле с проверкой текущего пароля.
|
||||
2. Сброс пароля по email откладывается до v0.1.0 (вместе с релизом записи
|
||||
конференций либо ранее отдельным патчем).
|
||||
3. Вариант «смена пароля на странице login без проверки старого пароля»
|
||||
отвергнут как небезопасный.
|
||||
4. Операционный обходной путь для забытого пароля в v0.0.1: пользователь
|
||||
обращается к администратору; администратор создаёт пользователей сам и
|
||||
знает выданный пароль. Возможность админа задать новый пароль
|
||||
существующему пользователю в скоуп не добавляется — при необходимости
|
||||
решается отдельно.
|
||||
5. Страница login не меняется: ссылку «Забыли пароль?» не добавляем, чтобы
|
||||
не обещать отсутствующую функцию.
|
||||
|
||||
## Последствия
|
||||
- Плюс: минимальный дифф перед тегом, нет спешной реализации
|
||||
security-чувствительного публичного флоу.
|
||||
- Минус: пользователь, забывший пароль, в v0.0.1 зависит от администратора.
|
||||
- Требования к будущей реализации (v0.1.0): одноразовый токен с TTL,
|
||||
хэш токена в хранилище (не сам токен), rate limit и uniform-ответ
|
||||
«письмо отправлено, если адрес зарегистрирован», отзыв refresh-токенов
|
||||
после смены пароля.
|
||||
312
docs/architecture/conference-room-ui.md
Normal file
312
docs/architecture/conference-room-ui.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Интерфейс комнаты конференции
|
||||
|
||||
**Ссылки:** `frontend/src/pages/RoomPage.tsx`, `frontend/src/components/room/*`, `@livekit/components-react`, LiveKit JS SDK
|
||||
|
||||
## Обзор
|
||||
|
||||
Комната конференции — это отдельный замкнутый UI с собственной тёмной темой (§ frontend-themes.md):
|
||||
- Диалог настроек устройств (микрофон, камера) с персист выбора
|
||||
- Аватары участников (или инициалы при отсутствии)
|
||||
- Fullscreen API (кнопка)
|
||||
- Мини-плеер: Document Picture-in-Picture для активной плитки (Chrome/Edge 116+) с фолбэком на Video Picture-in-Picture (Safari/Firefox)
|
||||
- Демонстрация экрана/окна/вкладки (любой участник, focus-раскладка, last-wins, звук где браузер отдаёт)
|
||||
|
||||
## 1. Настройки устройств (Device Settings)
|
||||
|
||||
### Архитектура
|
||||
|
||||
**UI (`DeviceSettingsDialog.tsx`):**
|
||||
- Кнопка-шестерёнка в тулбаре комнаты → диалог настроек
|
||||
- Селекты «Микрофон» и «Камера»
|
||||
|
||||
Список устройств, переключение активного и персист выбора между заходами в
|
||||
комнату целиком делегированы хукам `@livekit/components-react`:
|
||||
- `useMediaDeviceSelect({ kind })` — список устройств (подписан на
|
||||
`RoomEvent.MediaDevicesChanged`), `activeDeviceId`, `setActiveMediaDevice()`
|
||||
- `usePersistentUserChoices()` — сохраняет выбранные `deviceId` (localStorage,
|
||||
ключи и формат — внутренняя реализация библиотеки); `RoomPage.tsx` читает
|
||||
сохранённый выбор через `options`-проп `LiveKitRoom`, чтобы применить его
|
||||
при следующем входе
|
||||
|
||||
Диалог должен рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без
|
||||
явно переданного `room` берёт активную комнату из `RoomContext`.
|
||||
|
||||
**Ключевой момент:** ошибка переключения устройства (занято/отключено)
|
||||
показывается тостом; `activeDeviceId` хука остаётся источником истины —
|
||||
состояние селекта само не «откатывается».
|
||||
|
||||
## 2. Аватары участников
|
||||
|
||||
### Механизм отображения
|
||||
|
||||
Общий компонент `frontend/src/components/ui/Avatar.tsx` используется и в
|
||||
топбаре/админке/пикере участников, и в комнате (`RoomParticipantTile.tsx`):
|
||||
- Если передан `avatarUrl` — рендерится `<img>`
|
||||
- Иначе — инициалы имени (первые буквы первых двух слов), на фоне базового
|
||||
класса `.avatar`; отдельного визуального различия между зарегистрированным
|
||||
пользователем без аватара и гостем нет — оба показывают инициалы одинаково
|
||||
|
||||
### Передача аватара в LiveKit
|
||||
|
||||
LiveKit-токен (выдаётся `POST /api/v1/conferences/{id}/join` для
|
||||
зарегистрированных участников) содержит метаданные:
|
||||
|
||||
```json
|
||||
{
|
||||
"metadata": "{\"avatar_url\": \"https://vidconf.example.com/media/avatars/550e8400....jpg\"}"
|
||||
}
|
||||
```
|
||||
|
||||
**Парсинг** — инлайн-функция `parseAvatarUrl()` в `RoomParticipantTile.tsx`:
|
||||
разбирает `participant.metadata` (реактивно, через `useParticipantInfo`),
|
||||
возвращает `null` при пустых/невалидных метаданных. Для гостей `avatar_url` в
|
||||
токен не кладётся — `parseAvatarUrl` вернёт `null`, показываются инициалы.
|
||||
|
||||
## 3. Fullscreen API
|
||||
|
||||
### Реализация
|
||||
|
||||
Хук `frontend/src/hooks/useFullscreen.ts` — единственный источник истины о
|
||||
состоянии — событие `fullscreenchange` документа (не промис
|
||||
`requestFullscreen()`: выход по Esc браузер выполняет сам, без обратного
|
||||
вызова). Цель — корневой контейнер комнаты (`div[data-theme="room"]` в
|
||||
`RoomPage.tsx`), чтобы тулбар и чат оставались видны внутри полноэкранного
|
||||
режима. `supported` = `document.fullscreenEnabled` — кнопка в
|
||||
`RoomToolbar.tsx` скрывается, если `false`.
|
||||
|
||||
**Поддержка:** все современные браузеры (Chrome, Firefox, Safari, Edge).
|
||||
|
||||
## 4. Мини-плеер (Picture-in-Picture)
|
||||
|
||||
### Матрица поддержки
|
||||
|
||||
| Браузер | Document PiP | Video PiP | Что использует |
|
||||
|---|---|---|---|
|
||||
| **Chrome 116+ / Edge 116+** | ✓ Да | ✓ Да | Document PiP (активная плитка в отдельном окне) |
|
||||
| **Safari 17+** | ✗ Нет | ✓ Да | Video PiP (только видео активной плитки) |
|
||||
| **Firefox** | ✗ Нет | ✓ Да | Video PiP (только видео активной плитки) |
|
||||
|
||||
Оба флага детектируются в рантайме (`'documentPictureInPicture' in window`,
|
||||
`document.pictureInPictureEnabled`) — если ни один браузер API не
|
||||
поддерживает, `supported: false` и кнопка скрывается (очень старые браузеры).
|
||||
|
||||
### Единый хук `useRoomPiP`
|
||||
|
||||
`frontend/src/hooks/useRoomPiP.ts` инкапсулирует оба режима за одним API
|
||||
(`supported`, `active`, `mode`, `pipWindow`, `toggle`):
|
||||
|
||||
1. **Document Picture-in-Picture** (Chrome/Edge) — `toggle()` синхронно (в
|
||||
рамках user gesture) вызывает `window.documentPictureInPicture.requestWindow()`,
|
||||
копирует таблицы стилей текущего документа в PiP-окно (`copyStyleSheets`;
|
||||
внешние cross-origin стили — ссылкой `<link>`, не инлайном) и проставляет
|
||||
`data-theme="room"` на `<html>` PiP-окна. Содержимое — `RoomPage.tsx`
|
||||
рендерит `<RoomStage variant="pip" />` порталом (`createPortal`) прямо в
|
||||
`pipWindow.document.body`; React-контекст `LiveKitRoom` остаётся в основном
|
||||
дереве, поэтому хуки треков продолжают работать. В `variant="pip"` сцена
|
||||
показывает только одну активную плитку (без карусели/грида), фокус живо
|
||||
следует за активным спикером.
|
||||
2. **Video Picture-in-Picture** (Safari) — фолбэк, классический
|
||||
`videoEl.requestPictureInPicture()` на видео из фокус-плитки основного окна.
|
||||
3. Ни то, ни другое не поддерживается (Firefox) — `supported: false`, кнопка
|
||||
в тулбаре скрывается.
|
||||
|
||||
Ошибки открытия (например, `requestWindow()` отклонён) показываются тостом,
|
||||
не проваливаются молча. Закрытие PiP-окна пользователем через системный
|
||||
крестик отслеживается через событие `pagehide` окна; при размонтировании
|
||||
хука (уход со страницы) осиротевшее PiP-окно закрывается явно.
|
||||
|
||||
## 5. Вёрстка и CSS
|
||||
|
||||
### Токены цвета комнаты
|
||||
|
||||
Комната всегда использует `[data-theme="room"]` и токены:
|
||||
```css
|
||||
[data-theme="room"] {
|
||||
--color-room-bg: #1E1E1E; /* Чёрный фон */
|
||||
--color-room-text-primary: #E8E8E8; /* Светлый текст */
|
||||
--color-room-mic-on: #7FDDA8; /* Мята (mic включен) */
|
||||
--color-room-mic-off: #EB93A1; /* Роза (mic выключен) */
|
||||
--color-room-camera-on: #7FDDA8; /* Мята (camera включена) */
|
||||
--color-room-camera-off: #EB93A1; /* Роза (camera выключена) */
|
||||
--color-room-speaker-ring: #D6A83D; /* Янтарь (спикер) */
|
||||
}
|
||||
```
|
||||
|
||||
### Аватар (CSS)
|
||||
|
||||
Базовый класс `.avatar` — общий для всей оболочки (`frontend/src/styles/shell.css`); в комнате плитка добавляет модификатор `.room-tile-avatar` (`frontend/src/styles/room.css`) для адаптивного размера внутри плитки участника:
|
||||
|
||||
```css
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-ink-700);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Модификатор для плитки участника комнаты — адаптивный размер */
|
||||
.room-tile-avatar {
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
max-width: 96px;
|
||||
max-height: 96px;
|
||||
font-size: clamp(12px, 3vw, 28px);
|
||||
font-weight: 700;
|
||||
}
|
||||
```
|
||||
|
||||
Отдельного визуального варианта для гостей нет — инициалы гостя рендерятся тем же `.avatar`.
|
||||
|
||||
### Диалог настроек устройств
|
||||
|
||||
Диалог использует общие модальные классы комнаты (`frontend/src/styles/room.css`):
|
||||
|
||||
```css
|
||||
.room-modal-overlay { /* полноэкранная подложка с затемнением */ }
|
||||
.room-modal-panel { /* сама карточка диалога, --color-room-bg фон */ }
|
||||
.room-modal-head { display: flex; align-items: flex-start; justify-content: space-between; }
|
||||
.room-modal-close { /* кнопка закрытия */ }
|
||||
```
|
||||
|
||||
## 6. Демонстрация экрана
|
||||
|
||||
### Обзор
|
||||
|
||||
Участники конференции (включая гостей) могут поделиться своим экраном или отдельным окном. Демонстрация — это перманентный источник видео (как и камера), публикуется через `Track.Source.ScreenShare`, отображается крупно в фокус-плитке при наличии, а другие участники — в карусели сбоку. Новый демонстратор автоматически перехватывает фокус (политика last-wins); предыдущий остаётся виден как обычная плитка в карусели.
|
||||
|
||||
### Управление (UI)
|
||||
|
||||
**Кнопка в тулбаре комнаты** (RoomToolbar.tsx, строки 128–142):
|
||||
- Иконка: `ScreenShare` / `ScreenShareOff` (из lucide-react)
|
||||
- Текст: «Демонстрация»
|
||||
- Состояние: отражает, активна ли локальная демонстрация текущего участника
|
||||
- Клик: вызывает `useTrackToggle({ source: Track.Source.ScreenShare, captureOptions: SCREEN_SHARE_CAPTURE_OPTIONS })`
|
||||
|
||||
**Жизненный цикл:**
|
||||
|
||||
1. **Начало демонстрации:** пользователь нажимает кнопку → браузер показывает диалог выбора экрана/окна/вкладки → пользователь выбирает источник или отменяет → состояние кнопки и сцена обновляются
|
||||
2. **Во время демонстрации:**
|
||||
- Трек ScreenShare остаётся активным (публикуется)
|
||||
- Сцена переходит на focus-раскладку (см. ниже)
|
||||
- Пользователь может закончить в любой момент: нажать кнопку ещё раз ИЛИ нажать системную кнопку браузера «Прекратить доступ» (в браузере, обычно справа в адресной строке) → состояние синхронизируется автоматически
|
||||
3. **Конец демонстрации:** трек удаляется, фокус переходит на активного спикера или первого участника
|
||||
|
||||
### Опции захвата (ScreenShareCaptureOptions)
|
||||
|
||||
Константа `SCREEN_SHARE_CAPTURE_OPTIONS` (RoomToolbar.tsx, строки 32–37):
|
||||
|
||||
```typescript
|
||||
{
|
||||
audio: true, // Захватывать звук (вкладка/экран, где доступно)
|
||||
selfBrowserSurface: 'exclude', // Не предлагать саму вкладку конференции
|
||||
surfaceSwitching: 'include', // Разрешить переключать источник во время демо
|
||||
systemAudio: 'include' // Не запрещать системный звук (если браузер отдаёт)
|
||||
}
|
||||
```
|
||||
|
||||
**Обработка ошибок:**
|
||||
- `NotAllowedError` (пользователь нажал «Отмена» в браузерном диалоге) — игнорируется молча
|
||||
- Прочие ошибки (например, `NotReadableError` при занятом источнике) — показываются в тосте: «Не удалось начать демонстрацию экрана»
|
||||
|
||||
### Отображение на сцене (RoomStage.tsx)
|
||||
|
||||
#### Focus-раскладка при активной демонстрации
|
||||
|
||||
При наличии хотя бы одного активного трека `Track.Source.ScreenShare` (`RoomStage.tsx`, переменная `hasScreenShare`):
|
||||
- `FocusLayoutContainer` включается БЕЗУСЛОВНО (независимо от количества участников)
|
||||
- **Фокус-плитка:** первая активная демонстрация (по приоритету выбора)
|
||||
- **Карусель слева:** все camera-треки + прочие screenshare-треки (проигравшие фокус)
|
||||
- Инициалы/аватары заменены плитками видео, но если камера выключена — показывается аватар
|
||||
|
||||
#### Политика last-wins для нескольких демонстраторов
|
||||
|
||||
Логика в функции `pickStageFocus` (stageFocus.ts):
|
||||
- Если новый участник запустил демонстрацию → её трек становится фокусом
|
||||
- Предыдущая демонстрация остаётся в карусели как обычная плитка (не удаляется)
|
||||
- Каждый демонстратор может остановить свою демонстрацию независимо
|
||||
|
||||
#### Остановка собственной демонстрации
|
||||
|
||||
В фокус-плитке, когда текущий участник демонстрирует экран:
|
||||
- Отображается чип/кнопка: «Вы демонстрируете экран» ← click → вызов `room.localParticipant.setScreenShareEnabled(false)`
|
||||
- Трек прекращается, фокус переходит на спикера
|
||||
- Логика в `RoomStage.tsx` (функция `handleStopSharing`, передаётся в `RoomParticipantTile`)
|
||||
|
||||
### Поведение в fullscreen/PiP
|
||||
|
||||
**Fullscreen:**
|
||||
- Демонстрация экрана работает в fullscreen-режиме как обычно
|
||||
- Focus-раскладка сохраняется: демонстрация крупно, участники узкой колонкой
|
||||
- Выход из fullscreen (кнопка или ESC) возвращает стандартный вид
|
||||
|
||||
**Document Picture-in-Picture (Chrome/Edge 116+):**
|
||||
- При открытии PiP-окна показывается только активная плитка (без карусели/грида)
|
||||
- Если активна демонстрация → в PiP она и отображается (фокус)
|
||||
- Фокус в PiP живо следует за активным спикером (`followSpeaker`), в отличие от основного окна
|
||||
- Закрытие PiP-окна возвращает вид на основное окно
|
||||
|
||||
**Video Picture-in-Picture (Safari/Firefox фолбэк):**
|
||||
- Показывает только активное видео из фокус-плитки основного окна
|
||||
- При активной демонстрации → в PiP видно именно её
|
||||
|
||||
### Права доступа
|
||||
|
||||
- **Любой участник** может начать демонстрацию экрана, включая гостей
|
||||
- Нет специальных прав или ролей для демонстрации
|
||||
- Ограничение: браузер может запросить разрешение на доступ к экрану у ОС (обычно да/нет в диалоге браузера)
|
||||
|
||||
### Звук демонстрации: матрица браузеров
|
||||
|
||||
Функция `getDisplayMedia` (WebRTC API) отдаёт аудио-дорожку демонстрации там, где браузер и ОС позволяют. Ниже матрица по браузерам и ОС.
|
||||
|
||||
| Браузер / ОС | Вкладка (tab) | Весь экран | Отдельное окно | Примечания |
|
||||
|---|:---:|:---:|:---:|---|
|
||||
| **Chrome/Edge — Windows** | ✓ Да | ✓ Да (системный звук) | ⚠ Обычно нет | Вкладка: звук вкладки; весь экран: системный звук; окно — редко отдаёт звук (зависит от окна) |
|
||||
| **Chrome/Edge — Linux** | ✓ Да | ✓ Да (PulseAudio) | ⚠ Редко | PulseAudio выбирает источник; окно ≈ как на Windows |
|
||||
| **Chrome/Edge — macOS** | ✓ Да | ✗ Нет (ОС не отдаёт) | ✗ Нет (ОС не отдаёт) | Вкладка работает; весь экран и окно — ОС macOS не предоставляет системный звук браузеру для безопасности |
|
||||
| **Safari 16+ — macOS** | ✓ Да | ✗ Нет | ✗ Нет | Поддержка `getDisplayMedia` есть, звук не отдаётся; некоторые поля ScreenShareCaptureOptions (systemAudio) игнорируются |
|
||||
| **Firefox — Windows/Linux/macOS** | ✓ Да | ✓ Да | ⚠ Редко | Firefox поддерживает getDisplayMedia, поля systemAudio/selfBrowserSurface могут игнорироваться — безопасная деградация |
|
||||
|
||||
**Ключевые моменты:**
|
||||
1. **SDK не фейлит старт без аудио:** если браузер не может захватить звук, `getDisplayMedia()` всё равно вернёт видео-дорожку (без аудио) — демонстрация работает, просто без звука
|
||||
2. **RoomAudioRenderer:** компонент (`RoomStage.tsx`) проигрывает аудио-дорожки удалённых демонстраций, если они присутствуют в треках
|
||||
3. **Почему macOS без системного звука?** Согласно WebRTC спецификации и политике безопасности Apple, браузеры на macOS не получают системный звук через `getDisplayMedia()` — только звук текущей вкладки. Пользователь должен явно выбрать вкладку (браузер, плеер, Zoom и т. д.) в диалоге браузера, чтобы захватить её звук.
|
||||
|
||||
## Примечания
|
||||
|
||||
1. **Аватары гостей:** гости не получают `avatar_url` в метаданных LiveKit-токена, поэтому всегда видят инициалы.
|
||||
2. **Document PiP требует взаимодействия:** запрос можно сделать только в ответ на `click` или похожий пользовательский жест (security policy браузера) — `toggle()` хука `useRoomPiP` поэтому вызывается синхронно из обработчика клика.
|
||||
3. **Video PiP показывает только одну плитку:** если нужна сетка целиком, используйте Document PiP (Chrome/Edge).
|
||||
4. **Fullscreen работает везде:** но некоторые браузеры могут показать UI-запрос перед вводом.
|
||||
5. **Персист выбора устройств** — через `usePersistentUserChoices` из `@livekit/components-react`; наличие сохранённого устройства не гарантирует, что оно всё ещё подключено — библиотека сама обрабатывает этот случай при следующем входе.
|
||||
6. **Демонстрация экрана требует пользовательского жеста:** браузер требует клика/касания перед открытием диалога выбора экрана (Permissions Policy, безопасность).
|
||||
7. **Звук демонстрации теряется в macOS:** если требуется захват системного звука, пользователю на Mac нужно выбрать конкретную вкладку браузера/плеера (не «весь экран»).
|
||||
|
||||
## Ссылки
|
||||
|
||||
- `frontend/src/pages/RoomPage.tsx` — главный компонент комнаты, подключение LiveKit, порталы PiP
|
||||
- `frontend/src/components/room/RoomToolbar.tsx` — тулбар (микрофон, камера, демонстрация, настройки, fullscreen, PiP, чат, выход)
|
||||
- `frontend/src/components/room/RoomStage.tsx` — сцена с focus-раскладкой и логикой демонстрации экрана
|
||||
- `frontend/src/components/room/stageFocus.ts` — чистая функция выбора фокуса (`pickStageFocus`, last-wins)
|
||||
- `frontend/src/components/room/RoomParticipantTile.tsx` — плитка участника, аватар, парсинг метаданных
|
||||
- `frontend/src/components/room/DeviceSettingsDialog.tsx` — диалог настроек микрофона/камеры
|
||||
- `frontend/src/hooks/useFullscreen.ts` — полноэкранный режим
|
||||
- `frontend/src/hooks/useRoomPiP.ts` — мини-плеер (Document PiP + Video PiP фолбэк)
|
||||
- [Document Picture-in-Picture Spec](https://w3c.github.io/document-picture-in-picture/) — W3C
|
||||
- [Picture-in-Picture Spec](https://www.w3.org/TR/picture-in-picture/) — W3C (video PiP)
|
||||
- [Fullscreen API](https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API) — MDN
|
||||
- [Screen Capture API (getDisplayMedia)](https://w3c.github.io/mediacapture-screen-share/) — W3C
|
||||
- `docs/architecture/frontend-themes.md` — тёмная тема комнаты
|
||||
224
docs/architecture/frontend-themes.md
Normal file
224
docs/architecture/frontend-themes.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Архитектура тем оболочки VidConf
|
||||
|
||||
**Ссылки:** `design/DESIGN_SYSTEM.md` §0–§0.1, `design/mockups/dark/README.md`, `frontend/README.md` раздел «Темы оболочки»
|
||||
|
||||
## Суть
|
||||
|
||||
Оболочка VidConf (auth, лобби, календарь, join, админка) поддерживает две темы:
|
||||
- **Светлая** — дефолт, индиго-на-белом
|
||||
- **Тёмная** — графит-мята-роза-янтарь
|
||||
|
||||
Переключатель (☀️/🌙) в UI; автодетект системной темы (`prefers-color-scheme`); выбор сохраняется в `localStorage`. Комната конференции — всегда своя тёмная тема, не затрагивается оболочкой.
|
||||
|
||||
## Механизм переключения
|
||||
|
||||
### Атрибут `data-theme` на `<html>`
|
||||
|
||||
```
|
||||
:root → светлая (индиго-на-белом)
|
||||
:root[data-theme="dark"] → тёмная (мята-на-графите)
|
||||
:root[data-theme="light"] → светлая (явный выбор)
|
||||
[data-theme="room"] → комната (отдельный набор токенов, не переключается)
|
||||
```
|
||||
|
||||
**Приоритет:**
|
||||
1. Явный выбор (`data-theme="light"` или `data-theme="dark"`), если есть
|
||||
2. Системная тема (`@media (prefers-color-scheme: dark)`), если явного выбора нет
|
||||
3. Светлая по дефолту, если ОС не поддерживает `prefers-color-scheme`
|
||||
|
||||
### Токены CSS (design/tokens.css)
|
||||
|
||||
```css
|
||||
/* Светлая — базовая */
|
||||
:root {
|
||||
--color-bg: #F1F1F1;
|
||||
--color-ink-900: #2E3454;
|
||||
--color-ink-700: #3B4D95; /* индиго-бренд */
|
||||
--color-accent: #D4F2E3; /* пастель-мята CTA */
|
||||
/* … ещё 20+ токенов */
|
||||
}
|
||||
|
||||
/* Тёмная — явный выбор пользователя */
|
||||
:root[data-theme="dark"] {
|
||||
--color-bg: #1E1E1E;
|
||||
--color-ink-900: #E8E8E8;
|
||||
--color-ink-700: #7FDDA8; /* мята вместо индиго */
|
||||
--color-accent: #7FDDA8; /* мята CTA */
|
||||
/* … новые значения, тот же набор переменных */
|
||||
}
|
||||
|
||||
/* Тёмная — автодетект ОС (если выбора нет) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) {
|
||||
/* тот же набор, что и [data-theme="dark"] */
|
||||
}
|
||||
}
|
||||
|
||||
/* Комната — независимый третий скоуп */
|
||||
[data-theme="room"] {
|
||||
--color-room-bg: #1E1E1E;
|
||||
--color-room-mic-on: #7FDDA8; /* пастель-мята */
|
||||
--color-room-mic-off: #EB93A1; /* пастель-роза */
|
||||
/* … отдельный неймспейс, не трогается переключателем */
|
||||
}
|
||||
```
|
||||
|
||||
**Ключевой момент:** весь CSS оболочки ссылается на переменные через `var()`, поэтому его не нужно менять под разные темы. Переиспользование `var()` обеспечивает переключение автоматически.
|
||||
|
||||
### Сохранение выбора (localStorage)
|
||||
|
||||
Ключ: `vidconf-theme` (строго совпадает между `frontend/index.html` и `src/hooks/useTheme.ts`).
|
||||
|
||||
Значения:
|
||||
- `'light'` → `data-theme="light"` на `<html>`
|
||||
- `'dark'` → `data-theme="dark"` на `<html>`
|
||||
- `null` / отсутствует → `data-theme` **не проставляется**, действует системная тема
|
||||
|
||||
## Реализация (React)
|
||||
|
||||
### Hook: `src/hooks/useTheme.ts`
|
||||
|
||||
```typescript
|
||||
export function useTheme() {
|
||||
// Читает явный выбор из localStorage при монтировании
|
||||
const [explicit, setExplicit] = useState<'light' | 'dark' | null>(() => readStoredTheme())
|
||||
|
||||
// Живое отслеживание системной темы (matchMedia listener)
|
||||
const [systemPrefersDark, setSystemPrefersDark] = useState(...)
|
||||
|
||||
// Вычисляет текущую активную тему: явный выбор ИЛИ системная
|
||||
const resolved: 'light' | 'dark' = explicit ?? (systemPrefersDark ? 'dark' : 'light')
|
||||
|
||||
// Проставляет data-theme на <html> и сохраняет в localStorage
|
||||
const setTheme = (theme: 'light' | 'dark') => { /* … */ }
|
||||
|
||||
return { resolved, setTheme }
|
||||
}
|
||||
```
|
||||
|
||||
**Особенность:** читает из localStorage ДО первого рендера (через инициализатор `useState`), чтобы синхронизировать с инлайн-скриптом в index.html.
|
||||
|
||||
### Компонент: `src/components/ui/ThemeToggle.tsx`
|
||||
|
||||
```tsx
|
||||
export function ThemeToggle({ className = '' }: { className?: string }) {
|
||||
const { resolved, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="theme-toggle" role="group" aria-label="Переключить тему">
|
||||
<button
|
||||
className={resolved === 'light' ? 'is-active' : ''}
|
||||
onClick={() => setTheme('light')}
|
||||
aria-label="Светлая тема"
|
||||
>
|
||||
<Sun className="icon" />
|
||||
</button>
|
||||
<button
|
||||
className={resolved === 'dark' ? 'is-active' : ''}
|
||||
onClick={() => setTheme('dark')}
|
||||
aria-label="Тёмная тема"
|
||||
>
|
||||
<Moon className="icon" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Монтирование:**
|
||||
- `ShellTopbar.tsx` (лобби, календарь, админка, мои конференции)
|
||||
- `AuthLayout.tsx` (login, register, verify-email)
|
||||
- `JoinPage.tsx` (вход по номеру/ссылке)
|
||||
|
||||
### Анти-FOUC: инлайн-скрипт (frontend/index.html)
|
||||
|
||||
```html
|
||||
<script>
|
||||
try {
|
||||
var vidconfTheme = localStorage.getItem('vidconf-theme')
|
||||
if (vidconfTheme === 'light' || vidconfTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', vidconfTheme)
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage недоступен (приватный режим) — работает системная тема
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
Выполняется **до** бандла React, до `<div id="root">`. Исключает вспышку светлой темы при загрузке тёмного интерфейса.
|
||||
|
||||
## Палитра тёмной оболочки
|
||||
|
||||
Не новые цвета, а переиспользование уже утверждённых токенов комнаты:
|
||||
|
||||
| Роль | Светлая | Тёмная | Hex |
|
||||
|---|---|---|---|
|
||||
| Основной текст | `--color-ink-900` (индиго) | `--color-room-text-primary` | `#E8E8E8` |
|
||||
| Бренд/заголовки | `--color-ink-700` (индиго) | `--color-room-mic-on` (мята) | `#7FDDA8` |
|
||||
| CTA заливка | `--color-accent` (пастель-мята) | `--color-room-mic-on` (мята) | `#7FDDA8` |
|
||||
| Статус ошибки | `--color-danger` (красный) | `--color-room-mic-off` (роза) | `#EB93A1` |
|
||||
| Danger-кнопка (заливка) | `--color-danger` | `--color-danger-solid` | `#B85468` |
|
||||
| Warning/focus | `--color-warning` (янтарь) | `--color-room-speaker-ring` (янтарь) | `#D6A83D` |
|
||||
|
||||
**Новые токены для оболочки** (не в комнате, но получены смешиванием утверждённых цветов):
|
||||
- `--color-accent-text` (`#10331F`) — текст на мятных кнопках
|
||||
- `--color-accent-hover` / `--color-accent-active` — состояния CTA
|
||||
- `--color-ink-400` — подписи в скобках
|
||||
- `--color-success-bg` / `--color-danger-bg` / `--color-warning-bg` — подложки бейджей
|
||||
|
||||
Подробный расчёт (WCAG 2.1, контраст ≥ 4.5:1) — см. `design/mockups/dark/README.md`.
|
||||
|
||||
## Комната конференции (инвариант)
|
||||
|
||||
`[data-theme="room"]` — скоуп контейнера экрана конференции (обычно на `<div class="room-container">`):
|
||||
- Использует отдельный набор токенов `--color-room-*` (§1.2a `DESIGN_SYSTEM.md`)
|
||||
- **Никогда** не переключается на светлую тему
|
||||
- **Не слушает** `prefers-color-scheme` и `data-theme` на `<html>`
|
||||
- Всегда тёмная, независимо от выбора пользователя в лобби
|
||||
|
||||
Пример в `RoomPage.tsx`:
|
||||
```tsx
|
||||
return <div data-theme="room" className="room-container">
|
||||
{/* весь контент комнаты — видео, участники, чат, тулбар */}
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS страниц оболочки
|
||||
|
||||
Каждая страница оболочки (auth, lobby, calendar, join, my-conferences, admin) имеет свой файл стилей:
|
||||
- `frontend/src/styles/auth.css`
|
||||
- `frontend/src/styles/lobby.css`
|
||||
- `frontend/src/styles/calendar.css`
|
||||
- `frontend/src/styles/join.css`
|
||||
- `frontend/src/styles/my-conferences.css`
|
||||
- `frontend/src/styles/admin.css`
|
||||
|
||||
Они содержат точечные правки сверх токенов (декоративные градиенты, тени, layout-контроль), но **не задают цвета** — цвета задаются через `var()` из `tokens.css`. При переключении темы CSS-переменные меняются автоматически. Отдельные патчи под тёмную тему нужны там, где значение задано литеральным hex, а не через `var()` — см. комментарии в файлах.
|
||||
|
||||
## Тестирование
|
||||
|
||||
```bash
|
||||
# Проверить, что localStorage-ключ совпадает
|
||||
grep -n 'vidconf-theme' frontend/index.html
|
||||
grep -n 'STORAGE_KEY' frontend/src/hooks/useTheme.ts
|
||||
|
||||
# Запустить dev сервер
|
||||
cd frontend && npm run dev
|
||||
|
||||
# Выключить в DevTools: localStorage → удалить vidconf-theme
|
||||
# Проверить, что тема следует prefers-color-scheme ОС
|
||||
|
||||
# Кликнуть на переключатель (☀️/🌙)
|
||||
# Проверить, что localStorage получил видconf-theme='dark' или 'light'
|
||||
# Перезагрузить (F5) — тема должна восстановиться без вспышки
|
||||
|
||||
# Открыть RoomPage — комната должна остаться тёмной, несмотря на светлую оболочку
|
||||
```
|
||||
|
||||
## Ссылки
|
||||
|
||||
- `design/DESIGN_SYSTEM.md` — полная дизайн-система (§0–§1.2b)
|
||||
- `design/tokens.css` — авторитетный набор переменных (синхронизировано с frontend/src/styles/tokens.css)
|
||||
- `design/mockups/dark/` — макеты тёмной оболочки (утверждены)
|
||||
- `frontend/README.md` — раздел «Темы оболочки»
|
||||
- `design/tools/contrast.py` — скрипт проверки контраста WCAG 2.1 (покрывает и тёмные пары)
|
||||
Reference in New Issue
Block a user