Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c127b895e | ||
|
|
19b2365b4c | ||
|
|
76d36e5c35 | ||
|
|
32aabe9a80 | ||
|
|
2363d099d2 | ||
|
|
b931c0a760 | ||
|
|
a84f33307e | ||
|
|
0dc8ffdb87 | ||
|
|
2377707297 | ||
|
|
70b0c7ae9b | ||
|
|
104bdc4f49 | ||
|
|
449b86955e | ||
|
|
55c4b281c3 | ||
|
|
45033bffa8 | ||
|
|
884188e9ec | ||
|
|
4c63a46d24 | ||
|
|
7367d66ac3 | ||
|
|
e8c15cc425 | ||
|
|
88dbff0136 | ||
|
|
00fbf41efb | ||
|
|
1a25ce7170 | ||
|
|
403574fe79 | ||
|
|
31542de33b | ||
|
|
2afe676969 |
@@ -4,6 +4,18 @@ DB_PASSWORD=случайная-длинная-строка
|
|||||||
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
|
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
|
||||||
BACKUP_UPLOAD_LIMIT_MB=500
|
BACKUP_UPLOAD_LIMIT_MB=500
|
||||||
|
|
||||||
|
# === Лимиты загрузки файлов ===
|
||||||
|
# Максимальный размер одного файла, МБ (по умолчанию 50). Применяется ко всем
|
||||||
|
# загрузкам: файлы проекта и фото учеников, фото групп и учеников, логотип,
|
||||||
|
# фото модулей. Нельзя делать меньше UPLOAD_TOTAL_LIMIT_MB.
|
||||||
|
UPLOAD_FILE_LIMIT_MB=50
|
||||||
|
# Максимальный суммарный размер вложений одной записи, МБ (по умолчанию 200).
|
||||||
|
UPLOAD_TOTAL_LIMIT_MB=200
|
||||||
|
# Таймаут приёма запроса, мс. Пусто — считается автоматически
|
||||||
|
# (UPLOAD_TOTAL_LIMIT_MB * 7500, минимум 5 минут). Увеличьте, если ученики
|
||||||
|
# жалуются на обрыв загрузки на медленной мобильной связи.
|
||||||
|
UPLOAD_REQUEST_TIMEOUT_MS=
|
||||||
|
|
||||||
# === Redis (кэш, rate limit, баны IP, pub/sub) ===
|
# === Redis (кэш, rate limit, баны IP, pub/sub) ===
|
||||||
# Пароль Redis. Обязателен, если Redis включён в docker-compose.
|
# Пароль Redis. Обязателен, если Redis включён в docker-compose.
|
||||||
REDIS_PASSWORD=замените-на-длинный-секрет
|
REDIS_PASSWORD=замените-на-длинный-секрет
|
||||||
@@ -36,9 +48,39 @@ CLOUDFLARE_TUNNEL_URL=http://app:3003
|
|||||||
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
|
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
|
||||||
WG_HANDSHAKE_TIMEOUT=60
|
WG_HANDSHAKE_TIMEOUT=60
|
||||||
|
|
||||||
# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai
|
# === ИИ-улучшение фото (контейнер photo-ai, Real-ESRGAN + GFPGAN) ===
|
||||||
PHOTO_AI_URL=
|
# По умолчанию фото-ИИ включено: сервис photo-ai поднимается вместе со стеком
|
||||||
|
# и работает на CPU. Оставьте значение пустым, чтобы выключить сервис —
|
||||||
|
# тогда кнопка «🤖 ИИ» скрыта, а /api/entries/:id/photo/enhance-ai отвечает 503.
|
||||||
|
# Ручные проверки сервиса идут по loopback-порту 127.0.0.1:8081 (наружу не публикуется):
|
||||||
|
# curl http://127.0.0.1:8081/health
|
||||||
|
PHOTO_AI_URL=http://photo-ai:8080
|
||||||
|
# Максимум пикселей входного изображения, более крупный вход уменьшается.
|
||||||
PHOTO_AI_MAX_PIXELS=4000000
|
PHOTO_AI_MAX_PIXELS=4000000
|
||||||
|
# Устройство инференса: auto (CUDA, если контейнеру выдан GPU, иначе CPU), cuda, cpu.
|
||||||
|
# Явный cuda без доступной CUDA не роняет сервис: WARN в лог и работа на CPU.
|
||||||
|
# GPU-вариант собирается оверрайдом (отдельный образ whatido-photo-ai:cu126, GPU через CDI):
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||||
|
# Подробности установки NVIDIA Container Toolkit — в README, раздел «Запуск на NVIDIA GPU».
|
||||||
|
PHOTO_AI_DEVICE=auto
|
||||||
|
# Размер тайла инференса (0 — без тайлов). Меньше тайл — меньше памяти, медленнее.
|
||||||
|
# На GPU с 4 ГБ VRAM держите 256: при нехватке сервис сам пройдёт лестницу тайлов и уйдёт на CPU.
|
||||||
|
PHOTO_AI_TILE=256
|
||||||
|
# Модель восстановления лиц: gfpgan (по умолчанию) или codeformer.
|
||||||
|
# codeformer доступен только если модуль вендорен в photo-ai/vendor.
|
||||||
|
PHOTO_AI_FACE_MODEL=gfpgan
|
||||||
|
# Предзагрузка всех моделей при старте (1) или ленивая загрузка по требованию (0).
|
||||||
|
PHOTO_AI_LOAD_ALL=0
|
||||||
|
# Качество JPEG результата (70..100).
|
||||||
|
PHOTO_AI_JPEG_QUALITY=92
|
||||||
|
# Таймаут заданий с восстановлением лиц на стороне приложения (мс).
|
||||||
|
PHOTO_AI_FACE_TIMEOUT_MS=600000
|
||||||
|
# Сколько раз воркер повторит задание, если photo-ai недоступен (503/обрыв сети),
|
||||||
|
# не увеличивая attempts; после исчерпания — одна честная ошибка в задании.
|
||||||
|
PHOTO_AI_SOFT_MAX_RETRIES=60
|
||||||
|
# Пауза перед мягким повтором и её потолок (мс), задержка растёт вдвое до потолка.
|
||||||
|
PHOTO_AI_SOFT_BACKOFF_MS=10000
|
||||||
|
PHOTO_AI_SOFT_BACKOFF_MAX_MS=300000
|
||||||
|
|
||||||
# === Хранилище файлов (S3: SeaweedFS по умолчанию / MinIO) ===
|
# === Хранилище файлов (S3: SeaweedFS по умолчанию / MinIO) ===
|
||||||
# local — файлы в ./uploads (по умолчанию), s3 — объекты в бакете S3/MinIO.
|
# local — файлы в ./uploads (по умолчанию), s3 — объекты в бакете S3/MinIO.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly.
|
This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly.
|
||||||
|
|
||||||
|
> **Обязательное правило:** разведку, чтение и анализ репозитория делают **субагенты**, а не основной агент — контекст основного агента не должен раздуваться простынями кода. См. [Agent Workflow](#agent-workflow-обязательные-правила-работы).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
@@ -15,6 +17,37 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Agent Workflow (обязательные правила работы)
|
||||||
|
|
||||||
|
**Главное правило: исследование репозитория выполняют субагенты, а не основной агент.** Контекст основного агента — самый дорогой ресурс проекта: он живёт дольше одной задачи и должен содержать план, решения и точные точки правки, а не выгрузку файлов. Любой поиск, чтение и анализ файлов, которые пользователь явно не назвал, отдаются субагенту (`spawn_agent`).
|
||||||
|
|
||||||
|
### Что делегировать суагенту (обязательно)
|
||||||
|
- **Разведку**: «где реализовано X», «какие эндпоинты трогают Y», «кто вызывает Z» — `search_codebase` + точечное чтение.
|
||||||
|
- **Чтение крупных файлов**: `server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`, `AGENTS.md` целиком. Субагент возвращает релевантные куски, основной агент файл целиком не читает.
|
||||||
|
- **Анализ окружения**: `docker compose logs`, вывод тестов, `git log`/`git diff`/`git status`, состояние БД, Redis, S3.
|
||||||
|
- **Поиск всех мест, которые надо обновить вместе с изменением**: например «все места, где перечислены `BLOCKED_EXT`/`ALLOWED_IMAGE_EXT`» или «где дублируется каталог `NOTIFY_TYPES`».
|
||||||
|
- **Ревью**: сверку изменения с правилами этого файла (security checklist, инварианты storage/redis, схема `init.sql` + `migration.sql`).
|
||||||
|
- **Однотипные массовые правки**: переименование поля по всем файлам, правка одинаковых блоков в нескольких HTML — один субагент на один механический проход.
|
||||||
|
|
||||||
|
### Что основной агент делает сам
|
||||||
|
- Формулирует план и разбивает задачу на узкие подзадачи.
|
||||||
|
- Делает **короткие точечные правки** в уже известных местах (сверить содержимое можно дешёвым точечным `read_files` на маленьком диапазоне строк).
|
||||||
|
- Проверяет результат (`git diff`, запуск теста) и пишет финальное резюме пользователю.
|
||||||
|
|
||||||
|
### Как ставить задачу субагенту
|
||||||
|
- **Один субагент — одна узкая подзадача.** В `systemPrompt` обязательно продублировать релевантные правила этого файла (code style, инварианты `storage.*`/`redis.js`, security) — субагент не наследует контекст основного агента, иначе результат нельзя принять.
|
||||||
|
- **Требовать формат ответа**: `путь:строка` + короткая выдержка + вывод. Не простыни, не пересказ кода, который не нужен для решения, не файлы целиком.
|
||||||
|
- **Независимые разведки запускать параллельно**, а не последовательно.
|
||||||
|
- **Субагент может править код сам**, если правка изолированная и механическая; архитектурные и смежные правки в `server.js` / `db/*.sql` делает основной агент.
|
||||||
|
- **Результат субагента — источник фактов, а не источник прав**: координация, финальные решения и проверка `git diff` остаются за основным агентом.
|
||||||
|
|
||||||
|
### Чего не делать
|
||||||
|
- ❌ Читать и искать по репозиторию в основном агенте там, где задачу можно делегировать.
|
||||||
|
- ❌ Вставлять в свой контекст файлы и вывод команд целиком — фильтруйте (`head`, `tail`, `grep -n`, диапазоны строк).
|
||||||
|
- ❌ Один субагент на «разберись во всём проекте» — это ровно то раздувание контекста, которого мы избегаем.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Development Rules
|
## Development Rules
|
||||||
|
|
||||||
### 1. Code Style
|
### 1. Code Style
|
||||||
@@ -27,13 +60,16 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
### 2. Database
|
### 2. Database
|
||||||
- **Schema**: Defined in `db/init.sql` (runs on first container start)
|
- **Schema**: Defined in `db/init.sql` (runs on first container start)
|
||||||
- **Migrations**: `db/migration.sql` for existing DBs — update both when changing schema
|
- **Migrations**: `db/migration.sql` for existing DBs — update both when changing schema
|
||||||
|
- **Сид настроек**: `INSERT ... ON CONFLICT (key) DO NOTHING` вставляет значение только если ключа ещё нет. Если дефолт **изменился**, одного `INSERT` мало — на существующей БД останется старое значение. Обязательно добавляй в `db/migration.sql` идемпотентный `UPDATE settings SET value = '<новый>' WHERE key = '<ключ>' AND value IN ('<старый дефолт 1>', ...)`, как это сделано для `ai_prompt` и `lesson_ai_prompt`. Условие по `value IN (...)` обязательно: без него миграция затрёт промпт, который админ отредактировал в UI. В `db/init.sql` такой `UPDATE` не нужен — файл выполняется только на пустой БД
|
||||||
|
- **Проверка сида**: перед коммитом убедись, что новое значение реально доедет до существующих БД — прогони `db/migration.sql` в транзакции с откатом (`BEGIN;` + файл + `SELECT` + `ROLLBACK;`) и убедись, что `ON_ERROR_STOP=1` не дал ошибок
|
||||||
- **Connection**: Single `Pool` from `pg`, `DATABASE_URL` from env
|
- **Connection**: Single `Pool` from `pg`, `DATABASE_URL` from env
|
||||||
- **Queries**: Parameterized only (`$1`, `$2`...), never string interpolation
|
- **Queries**: Parameterized only (`$1`, `$2`...), never string interpolation
|
||||||
- **Transactions**: Use `client.query('BEGIN')` / `COMMIT` / `ROLLBACK` for multi-statement ops
|
- **Transactions**: Use `client.query('BEGIN')` / `COMMIT` / `ROLLBACK` for multi-statement ops
|
||||||
|
|
||||||
### 3. File Uploads
|
### 3. File Uploads
|
||||||
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
|
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
|
||||||
- **Limits**: 10 MB/file, 30 MB total per entry
|
- **Видео в интерфейсе**: `mp4`/`m4v`/`webm`/`ogv` играются в модалке `#videoModal` в `journal.html` (`data-video` в `filesHTML`); остальные видео (`mov`, `mkv`, `avi`, …) остаются обычными ссылками на скачивание. Отдача — `GET /api/files/:token?play=1` **inline** с `Accept-Ranges`; без `?play=1` файл по-прежнему уходит как `attachment`, чтобы старые ссылки не поменяли поведение
|
||||||
|
- **Limits**: `UPLOAD_FILE_LIMIT_MB` per file (default 50), `UPLOAD_TOTAL_LIMIT_MB` per entry (default 200) — both env-driven; `UPLOAD_REQUEST_TIMEOUT_MS` overrides the auto-computed request timeout. The frontend reads the two MB values from `GET /api/public-settings` (`upload_file_limit_mb`, `upload_total_limit_mb`) — do not hardcode them again in `public/js/index.js`
|
||||||
- **Staging**: Multer always writes to `uploads/` (`timestamp-random.ext`); a global `res.on('finish')` hook persists each uploaded file through `storage.persist` on successful responses (only when `STORAGE_DRIVER=s3`)
|
- **Staging**: Multer always writes to `uploads/` (`timestamp-random.ext`); a global `res.on('finish')` hook persists each uploaded file through `storage.persist` on successful responses (only when `STORAGE_DRIVER=s3`)
|
||||||
- **HEIC**: Auto-converted to JPEG via `heic-convert`
|
- **HEIC**: Auto-converted to JPEG via `heic-convert`
|
||||||
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` or the configured bucket
|
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` or the configured bucket
|
||||||
@@ -41,7 +77,8 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
### 3a. Storage (`storage.js`)
|
### 3a. Storage (`storage.js`)
|
||||||
- **Drivers**: `local` (default, files in `uploads/`) and `s3` (S3-compatible: SeaweedFS by default, MinIO via `docker-compose.minio.yml`)
|
- **Drivers**: `local` (default, files in `uploads/`) and `s3` (S3-compatible: SeaweedFS by default, MinIO via `docker-compose.minio.yml`)
|
||||||
- **Keys are stable**: DB stores `/uploads/<name>`; S3 object keys are the same `<name>` (plus `.originals/<name>`). Never change key format — it would break existing DB rows and URLs
|
- **Keys are stable**: DB stores `/uploads/<name>`; S3 object keys are the same `<name>` (plus `.originals/<name>`). Never change key format — it would break existing DB rows and URLs
|
||||||
- **API**: `put`, `putFile`, `head`, `exists`, `sizeOf`, `getStream`, `getBuffer`, `del`, `copyObject`, `listAll`, `localize`, `persist`, `streamTo`, `downloadAll`, `uploadTree`, `ensureBucket`, `usage`, `pruneCache`
|
- **API**: `put`, `putFile`, `head`, `exists`, `sizeOf`, `getStream`, `getBuffer`, `getRange`, `del`, `copyObject`, `listAll`, `localize`, `persist`, `streamTo`, `streamRangeTo`, `downloadAll`, `uploadTree`, `ensureBucket`, `usage`, `pruneCache`
|
||||||
|
- **Диапазоны**: `getRange(key, start, end)` и `streamRangeTo(res, key, start, end, opts)` отдают `206` с `Content-Range`/`Accept-Ranges` — только для медиа, разбор `Range` на стороне сервера (`parseByteRange`)
|
||||||
- **Rules**: never call `fs.*` on `uploads/` directly in request/worker code — use `storage.*`. `safeUnlink` is the only deletion helper (local + remote, idempotent)
|
- **Rules**: never call `fs.*` on `uploads/` directly in request/worker code — use `storage.*`. `safeUnlink` is the only deletion helper (local + remote, idempotent)
|
||||||
- **Read path**: `STORAGE_LOCAL_FALLBACK=1` prefers a local file when it still exists (covers in-flight uploads and partial migration); otherwise the app streams the object from S3
|
- **Read path**: `STORAGE_LOCAL_FALLBACK=1` prefers a local file when it still exists (covers in-flight uploads and partial migration); otherwise the app streams the object from S3
|
||||||
- **Cache**: `.thumbs` (WebP miniatures) and `.cache` (originals localized for sharp/zip) live inside `uploads/` and are pruned hourly (`STORAGE_CACHE_MAX_AGE_HOURS`)
|
- **Cache**: `.thumbs` (WebP miniatures) and `.cache` (originals localized for sharp/zip) live inside `uploads/` and are pruned hourly (`STORAGE_CACHE_MAX_AGE_HOURS`)
|
||||||
@@ -58,10 +95,47 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
- **`resetTime` в `rateLimitStore.increment` обязан быть `Date`** — express-rate-limit v8 вызывает `resetTime.getTime()`
|
- **`resetTime` в `rateLimitStore.increment` обязан быть `Date`** — express-rate-limit v8 вызывает `resetTime.getTime()`
|
||||||
- **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату
|
- **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату
|
||||||
- **Инвалидация — по префиксу** (`SCAN` + `DEL`), точечного удаления по ключу избегайте
|
- **Инвалидация — по префиксу** (`SCAN` + `DEL`), точечного удаления по ключу избегайте
|
||||||
- **Ключевые пространства**: `setting:`, `groups:`, `students:`, `entries:`, `stats:`, `dashboard:`, `share:payload:`, `public-settings`, `system-info`, `session:`, `ban:`, `fail:`, `rl:`
|
- **Ключевые пространства**: `setting:`, `groups:`, `students:`, `entries:`, `lessons:`, `stats:`, `dashboard:`, `share:payload:`, `public-settings`, `system-info`, `session:`, `ban:`, `fail:`, `rl:`
|
||||||
- **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:<token>`, иначе деактивированный пользователь сохранит доступ
|
- **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:<token>`, иначе деактивированный пользователь сохранит доступ
|
||||||
- **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1`
|
- **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1`
|
||||||
|
|
||||||
|
### 3c. Уведомления (`server.js`, `worker.js`, `public/`)
|
||||||
|
- **Каталог событий** — только `NOTIFY_TYPES` в `server.js` (тип → `label`, `hint`, `icon`, `level`, `enabled` по умолчанию, `admin`); фронтенд берёт список из `GET /api/notifications/meta`, дублировать каталог в HTML нельзя
|
||||||
|
- **Таблицы**: `notifications` (событие, `admin_only`, `branch_id`) + `notification_reads` (прочтение на пользователя). Изменения схемы — в `db/init.sql` и `db/migration.sql` и в `ensureNotificationsTable()`
|
||||||
|
- **Настройки**: `notify_enabled` (общий), `notify_retention_days` (1–365), `notify_<тип>` (точки типа заменяются на `_`, см. `notifySettingKey`). Значения только `'true'` / `'false'` — `PUT /api/settings` это валидирует
|
||||||
|
- **Создание события** — только через `pushNotification()` / `notifyEntry()`; они сами проверяют переключатели и при выключенном типе возвращают `null`. `notifyEntry` подставляет `{student}` и `{group}` и определяет филиал по группе записи
|
||||||
|
- **Хук в фото-воркере называется `notifyEvent`** — имя `notify` внутри `createPhotoEnhanceWorker` уже занято будильником воркера (`photoWorker.notify()` из `POST /api/photo-jobs/wake`), объявление функции перекрыло бы параметр
|
||||||
|
- **Видимость**: админ видит всё; остальные — `admin_only = false` и `branch_id IS NULL` или филиал из `user_branches`
|
||||||
|
- **Доставка**: запись в БД → `cache.publish('whatido:notifications', row)` → SSE `GET /api/notifications/stream` (клиенты фильтруются по `notificationVisible`). Redis недоступен — работает in-memory pub/sub
|
||||||
|
- **Очистка**: `purgeOldNotifications()` при старте и раз в час по `notify_retention_days`; чтения удаляются каскадом
|
||||||
|
- **Новое событие добавляется вместе с**: записью в `NOTIFY_TYPES`, строками `INSERT INTO settings` в `db/init.sql` + `db/migration.sql`, вызовом `pushNotification`/`notifyEntry` в точке события и парой `icon` из Lucide
|
||||||
|
- **Аудит**: удаление/очистка уведомлений логируется (`notifications.delete`, `notifications.clear`)
|
||||||
|
|
||||||
|
### 3d. Отчёты о занятии и проверка по шаблону (`server.js`, `worker.js`, `public/`)
|
||||||
|
- **Таблицы**: `lesson_reports` (+ `text_original`, `text_ai`, `ai_status`, `ai_checked_at`, `ai_error`) и `lesson_report_versions` (история версий: `text`, `source` = `manual` | `ai` | `restore`). Схема — в `db/init.sql`, `db/migration.sql` и `ensureLessonReportsTable()`
|
||||||
|
- **Настройки**: `lesson_ai_enabled` (`'true'` / `'false'` — общий выклюжатель) и `lesson_ai_prompt` (шаблон делового сообщения + правила, пример вставляется в `db/init.sql`, `db/migration.sql` и в `LESSON_AI_DEFAULT_PROMPT` в `server.js`). Раздел в UI — `sec-lesson-ai` на `public/settings.html`
|
||||||
|
- **Смена шаблона промпта — это четыре правки, а не три**: новое значение в `LESSON_AI_DEFAULT_PROMPT` (`server.js`), `db/init.sql`, `db/migration.sql` (там же `INSERT` для свежих БД) и **обязательно** `UPDATE settings SET value = '<новый>' WHERE key = 'lesson_ai_prompt' AND value IN ('<старый дефолт>')` в `db/migration.sql` — без него правка в SQL-файлах действует только на свежие установки, а у всех, кто уже пользовался разделом `sec-lesson-ai`, в `settings` останется старый промпт (см. «Сид настроек» в разделе 2). Все три текстовые копии должны быть побайтово идентичны `LESSON_AI_DEFAULT_PROMPT`
|
||||||
|
- **Флаг из UI**: чекбокс `#lessonAiCheck` в модалке `#lessonModal` — включён при создании, выключен при редактировании (`resetLessonModalFields` / `fillLessonModalFromReport`). Уходит в теле как `ai_check`
|
||||||
|
- **Роут не ждёт модель**: `POST`/`PUT /api/lesson-reports` при `ai_check: true` сохраняют отчёт как есть и ставят `ai_status = 'pending'`, затем `wakeLessonAiWorker()`. Ответ возвращается сразу — не блокируйте HTTP-запрос вызовом модели
|
||||||
|
- **Воркер**: `createLessonReportChecker` в `worker.js` забирает `pending` через `FOR UPDATE OF lr SKIP LOCKED`, шлёт в модель текст + контекст (группа, дата, время), результат: без изменений → `skipped`, переписан → `done` (новый текст в `text` и `text_ai`), сбой → до 3 попыток, затем `error`
|
||||||
|
- **Доставка результата**: `onDone` в `server.js` пишет версию (`saveLessonReportVersion`), аудит с diff (`lesson_report.ai.format`), уведомление `lesson.ai.formatted` и SSE `lesson_report_status` на `EVENTS_CHANNEL`
|
||||||
|
- **История версий**: `GET /api/lesson-reports/:id/versions`, восстановление — `POST /api/lesson-reports/:id/versions/:versionId/restore`, откат к тексту тьютора — `POST /api/lesson-reports/:id/ai/revert`. Хранится последние `LESSON_AI_VERSION_LIMIT` версий на отчёт
|
||||||
|
- **Хуки фронтенда**: `openLessonVersions(id)` и `restoreLessonVersion(...)` живут в `public/admin.js` (модалка доступна с журнала, отчётов и дашборда), список и бейджи статусов — в `public/js/lessons.js`
|
||||||
|
|
||||||
|
### 3e. Дата, время и часовой пояс (`server.js`, `public/js/datetime.js`)
|
||||||
|
- **Настройки**: `timezone` (IANA, валидируется через `validTimezone`) и `time_format` (`'24h'` | `'12h'`). Сид — в `db/init.sql` + `db/migration.sql`. `DEFAULT_TIMEZONE` берётся из `process.env.TZ`, иначе `Europe/Moscow`
|
||||||
|
- **Единая точка**: `public/js/datetime.js` подключается на **каждой** странице (`public/*.html`) перед `admin.js`/`js/*.js`, включая публичные `share.html`, `report.html`, `index.html`. Инициализация — `await initDateTime()` (грузят `timezone`/`time_format` из `GET /api/public-settings`). На админ-страницах вызов встроен в `checkAuth()` в `admin.js`
|
||||||
|
- **Запрещено** в `public/`: прямые `toLocaleString`/`toLocaleDateString`/`toLocaleTimeString`, `new Date().getFullYear()` и `new Date().toISOString().slice(0,10)` для показа/вычисления дат. Только хелперы `datetime.js`
|
||||||
|
- **Три семейства данных — не путать**:
|
||||||
|
- *instant* (`TIMESTAMPTZ`, ISO c `Z`) → `fmtFull` / `fmtDateTime` / `fmtDateFull` / `fmtDayMonth` / `fmtDateShort` / `fmtDateLong` / `fmtTimeOnly`. Зона применяется
|
||||||
|
- *чистая DATE-строка* `'YYYY-MM-DD'` (`lesson_date`, `taken_at`, `date_from`) → `fmtDateOnlyIso` / `fmtShortIso` / `fmtDayMonthIso` / `fmtDateOnlyLongIso`. **Без `Date()`** — иначе `new Date('YYYY-MM-DD')` (UTC-полночь) сдвинет дату на день назад
|
||||||
|
- *чистая TIME-строка* `'HH:MM(:SS)'` (`lesson_time`, `time_start`/`time_end`) → `fmtHmStr`. Учитывает 12h/24h, зону не применяет
|
||||||
|
- **Текущие значения**: `todayIso()`, `nowHm()` (всегда 24h — для `<input type="time">`), `nowYear()`, `todayDow()`, `isoAddDays(iso, n)` (арифметика по ISO без зоны)
|
||||||
|
- **Границы дней в SQL**: `TIMESTAMPTZ`-колонки фильтруются **только** через `tzDayStart`/`tzDayEnd` + `bindTz` (плейсхолдер `$TZ$` → `$N`, зона добавляется в `params` последней). Прямой `$n::date` по `created_at` считает дни в UTC (у контейнера `TimeZone=UTC`) и молча ломает границы — такого кода быть не должно. `DATE`-колонки (`lr.lesson_date`) сравниваются напрямую, зона не нужна
|
||||||
|
- **`now()` по зоне**: `tzWall()` для «сейчас» и для `day_of_week`/расписания. Хардкод `'Europe/Moscow'` в SQL запрещён
|
||||||
|
- **`bindTz` добавляет параметр только если в SQL есть `$TZ$`**: иначе Postgres отвечает `bind message supplies 1 parameters, but prepared statement requires 0`, а без global error handler запрос **висит вечно** (страница остаётся «Загрузка...»). Поэтому запрос с фильтрами дат работает, а без них — падает: проверяй оба варианта. Регрессия закрыта в `api.smoketest.js`
|
||||||
|
- **`TZ` в compose** (`docker-compose.yml`, 5 мест) — только фолбэк для `DEFAULT_TIMEZONE`; фактическая зона берётся из настройки
|
||||||
|
|
||||||
### 4. API Patterns
|
### 4. API Patterns
|
||||||
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
|
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
|
||||||
- **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется
|
- **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется
|
||||||
@@ -95,15 +169,23 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
- Cert: app generates self-signed cert at build (`certs/cert.pem`), mounted into tailscale container
|
- Cert: app generates self-signed cert at build (`certs/cert.pem`), mounted into tailscale container
|
||||||
|
|
||||||
### 8. Backup / Restore
|
### 8. Backup / Restore
|
||||||
- **Admin UI**: `/api/backup` (download tar.gz), `/api/restore` (upload tar.gz)
|
- **Admin UI**: `POST /api/backup` → тикет + `GET /api/backup/:token` (ссылка живёт `BACKUP_TTL_MS`, 30 мин, **скачивание можно повторять** — в том числе после обрыва связи и F5; не «сжигать» тикет `HEAD`-пробой, Express 4 отдаёт HEAD через GET-хендлер), `POST /api/restore` (upload `.tar.gz`)
|
||||||
|
- **Хранение тикетов**: in-memory `Map` (`server.js`), поэтому после рестарта приложения ссылка даёт 404 — это ожидаемо. Одновременно живых тикетов не больше `BACKUP_TICKETS_MAX`, лишние и истёкшие вычищаются `pruneBackupTickets()`; файлы доживают `sweepBackupStorage()` с запасом в 5 минут сверх TTL
|
||||||
- **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level)
|
- **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level)
|
||||||
- Backup format: `data.json` (all tables) + `uploads/` directory
|
- Формат архива: `tar.gz` с `data.json` + `uploads/`. Версия формата — `BACKUP_FORMAT_VERSION` в `backup-restore.js` (сейчас `2`), принимаются версии `1..2`; версия пишется в `data.json.version` и возвращается в ответе `POST /api/backup` и `POST /api/restore`
|
||||||
- Restore validates all data, resets sequences, sweeps orphans
|
- `data.json` содержит `version`, `created_at`, `app` (версия/коммит), `counts` (строки по таблицам + `files`) и сами данные. Таблицы перечислены в `BACKUP_TABLES` — **при добавлении таблицы править её и в `buildBackupArchive`, и здесь**
|
||||||
|
- `sessions` в бэкап **не входит** намеренно: после restore все токены должны умереть. `audit_log`, `notifications`, `notification_reads`, `banned_ips` — входят
|
||||||
|
- Файлы: `storage.downloadAll` кладёт в архив всё, кроме регенерируемых `.thumbs/` и `.cache/`; `.originals/` (оригиналы фото до ИИ-обработки) **входят** и восстанавливаются через `uploadTree`
|
||||||
|
- Restore: валидация всего через `normalizeRestoreData` (`backup-restore.js`), транзакция с `DELETE` в FK-безопасном порядке → `INSERT` → `setval` по `BACKUP_SEQUENCE_TABLES` → файлы → `sweepOrphanedUploads()` → `loadBans()` → `invalidateAll()`
|
||||||
|
- **Колонки, которые normalizeRestoreData обязана сохранять**: `groups.deleted_at`/`purge_at`, `entries.purge_at`. Потеря `deleted_at` воскрешает мягко удалённые группы как активные — это не «мелочь», а порча данных
|
||||||
|
- `sweepOrphanedUploads()` считает ссылками фото из `entries.photo_path`/`photo_original_path`, `project_files.path`, `group_photos`, `entry_photos`, `student_photos`, `modules.photo_path`, `students.photo_path`, `groups.cover_path`, `photo_jobs.before_path`/`after_path`, `settings.system_logo`. Новая колонка с путём к файлу → добавить сюда, иначе sweep снесёт файл сразу после restore
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
|
Каждый рецепт ниже начинается с субагента-разведки (см. [Agent Workflow](#agent-workflow-обязательные-правила-работы)): пусть он найдёт нужные места и вернёт `путь:строка`, а правки вносит основной агент.
|
||||||
|
|
||||||
### Add a new API endpoint
|
### Add a new API endpoint
|
||||||
1. Add route in `server.js` (group with related routes)
|
1. Add route in `server.js` (group with related routes)
|
||||||
2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public
|
2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public
|
||||||
@@ -173,6 +255,10 @@ node redis.selftest.js # unit + degradation, ne
|
|||||||
node api.smoketest.js # e2e, needs running stack
|
node api.smoketest.js # e2e, needs running stack
|
||||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
|
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
|
||||||
docker compose stop redis && node api.smoketest.js # app must keep working in-memory
|
docker compose stop redis && node api.smoketest.js # app must keep working in-memory
|
||||||
|
|
||||||
|
# Backup checks
|
||||||
|
node backup.selftest.js # unit, no stack needed
|
||||||
|
curl -sk -X POST https://127.0.0.1:3443/api/backup -H "X-Auth-Token: $TOKEN" # -> counts по всем таблицам
|
||||||
docker compose start redis # app reconnects on its own
|
docker compose start redis # app reconnects on its own
|
||||||
|
|
||||||
# Audit diff checks
|
# Audit diff checks
|
||||||
@@ -228,15 +314,18 @@ guards against.
|
|||||||
| `redis.js` | Redis abstraction: cache, counters, rate-limit store, pub/sub, in-memory fallback |
|
| `redis.js` | Redis abstraction: cache, counters, rate-limit store, pub/sub, in-memory fallback |
|
||||||
| `redis.selftest.js` | Self-tests for `redis.js`, including behaviour with Redis unavailable |
|
| `redis.selftest.js` | Self-tests for `redis.js`, including behaviour with Redis unavailable |
|
||||||
| `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests |
|
| `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests |
|
||||||
|
| `backup-restore.js` / `backup.selftest.js` | Backup format version, `normalizeRestoreData` validation of restore payloads, backup table lists; self-tests |
|
||||||
| `api.smoketest.js` | End-to-end API smoke test against a running stack |
|
| `api.smoketest.js` | End-to-end API smoke test against a running stack |
|
||||||
| `worker.js` | Background AI auto-check worker for entry messages + photo enhance worker |
|
| `worker.js` | Background AI auto-check workers: entry messages, lesson-report template check, photo enhance |
|
||||||
| `db/init.sql` | Initial schema (runs on fresh DB) |
|
| `db/init.sql` | Initial schema (runs on fresh DB) |
|
||||||
| `db/migration.sql` | Idempotent migrations for existing DBs |
|
| `db/migration.sql` | Idempotent migrations for existing DBs |
|
||||||
| `docker-compose.yml` | Service definitions (app, db, s3, tailscale) |
|
| `docker-compose.yml` | Service definitions (app, db, s3, tailscale) |
|
||||||
| `docker-compose.minio.yml` | Override: S3 service backed by MinIO instead of SeaweedFS |
|
| `docker-compose.minio.yml` | Override: S3 service backed by MinIO instead of SeaweedFS |
|
||||||
| `Dockerfile` | App image build |
|
| `Dockerfile` | App image build |
|
||||||
| `public/*.html` | Frontend pages |
|
| `public/*.html` | Frontend pages |
|
||||||
| `public/admin.js` | Shared frontend logic |
|
| `public/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) |
|
||||||
|
| `public/js/datetime.js` | Единая точка форматирования дат/времени: часовой пояс + 24h/12h. Подключается на всех страницах |
|
||||||
|
| `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование |
|
||||||
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
|
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
|
||||||
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
|
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
|
||||||
| `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) |
|
| `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) |
|
||||||
@@ -263,6 +352,10 @@ guards against.
|
|||||||
- ❌ Expose DB port (5432) outside docker network
|
- ❌ Expose DB port (5432) outside docker network
|
||||||
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
|
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
|
||||||
- ❌ Add comments to code (this file excepted)
|
- ❌ Add comments to code (this file excepted)
|
||||||
|
- ❌ Read/search the repo in the main agent when the work can be delegated to a subagent
|
||||||
|
- ❌ Load whole files (`server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`) or raw command output into the main context — delegate and filter (`grep -n`, `head`, line ranges)
|
||||||
|
- ❌ Give a subagent an open-ended "explore the whole project" task, or omit the project rules from its `systemPrompt` — it does not inherit the main context
|
||||||
|
- ❌ Treat a subagent report as the final word: coordinate, edit and verify the result yourself (`git diff`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,553 @@
|
|||||||
|
# План: Улучшение лиц на фотографиях (Real-ESRGAN + GFPGAN/CodeFormer) с автовыбором GPU/CPU
|
||||||
|
|
||||||
|
Статус: **план, реализация не начата**. Документ описывает целевую архитектуру, изменения по файлам,
|
||||||
|
порядок внедрения и критерии приёмки. Реализацию начинать после знакомства с этим файлом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Постановка задачи
|
||||||
|
|
||||||
|
Сейчас система улучшает фотографии одной моделью `RealESRGAN_x2plus` (x2, CPU, `half=False`,
|
||||||
|
`device='cpu'`, захардкожено в `photo-ai/app.py:44`). Модель хорошо восстанавливает текстуры
|
||||||
|
(одежда, фон, бумага), но **лица** при апскейле часто получают артефакты, «пластиковую» кожу и
|
||||||
|
искажённые черты, потому что у `RealESRGAN_x2plus` нет приора на структуру лица.
|
||||||
|
|
||||||
|
Нужно:
|
||||||
|
|
||||||
|
1. Добавить модели восстановления **лиц** — GFPGAN v1.4 и/или CodeFormer (оба — face restoration
|
||||||
|
с prior-сетью, работают в связке с `RealESRGANer` как `bg_upsampler`).
|
||||||
|
2. Дать пользователю **выбор модели** для обработки: универсальная x2, face-модель, комбинация
|
||||||
|
(фон + лица), быстрая VGG-модель `realesr-general-x4v3`.
|
||||||
|
3. Поддержать работу **на GPU и на CPU** с **автоматическим выбором** устройства: есть рабочий
|
||||||
|
CUDA — используем GPU, нет — молча и без падений уходим на CPU.
|
||||||
|
4. Не допустить простоя GPU-контейнера: пока модели грузятся — сервис отвечает `503`, воркер
|
||||||
|
ждёт, а не теряет задания.
|
||||||
|
5. Не сломать существующие контракты: `photo_jobs`, `/enhance`, `PHOTO_AI_URL`, автономную работу
|
||||||
|
без `photo-ai` (`PHOTO_AI_URL` пустой → кнопка «ИИ» недоступна).
|
||||||
|
|
||||||
|
### Ограничения окружения (проверено на текущем хосте)
|
||||||
|
|
||||||
|
| Параметр | Значение | Следствие |
|
||||||
|
|----------|----------|-----------|
|
||||||
|
| GPU | `NVIDIA GeForce RTX 3050 ...`, 4096 MiB VRAM, драйвер 615.71.09, CUDA UMD 13.4 | GPU-режим реален, но 4 ГБ VRAM — тесно, нужен `tile` и запас |
|
||||||
|
| `/dev/dri` | `card1`, `card2`, `renderD128`, `renderD129` | iGPU тоже виден, но torch будет использовать CUDA |
|
||||||
|
| `nvidia-ctk` | **не установлен** | нужен NVIDIA Container Toolkit на хосте, иначе `--gpus` не заработает |
|
||||||
|
| Runtime Docker | только `runc` (нет `nvidia`) | требуется установка toolkit + `docker compose` override |
|
||||||
|
| CPU | Intel i5-12500H, 16 потоков | CPU-режим приемлем как fallback, но медленный |
|
||||||
|
| RAM | 15 GiB (занято ~9 ГБ), swap 31 GiB | CPU-модели + torch требуют ~2–3 ГБ; следить за OOM |
|
||||||
|
| Диск | 59 ГБ свободно на `/home` | веса: x2plus 64 МБ + GFPGAN 333 МБ + CodeFormer 360 МБ + wdn 64 МБ — ок |
|
||||||
|
| Docker / Compose | 29.8.1 / 5.5.1 | поддерживают `deploy.resources.reservations.devices` (Compose v5) |
|
||||||
|
|
||||||
|
Важно: **GPU-режим не должен быть обязательным условием запуска**. Если toolkit не установлен,
|
||||||
|
compose-файл с `devices` не поднимется — поэтому GPU выносим в отдельный override-файл.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Что уже есть (точки интеграции)
|
||||||
|
|
||||||
|
| Место | Что делает | Что меняем |
|
||||||
|
|-------|-----------|-----------|
|
||||||
|
| `photo-ai/app.py` | FastAPI, `POST /enhance` (`image`, `scale`), одна модель, `device='cpu'` | Расширяем до реестра моделей + автовыбор device + `POST /enhance` с `model`/`face` |
|
||||||
|
| `photo-ai/Dockerfile` | `python:3.10-slim`, torch CPU-only, правка `basicsr/data/degradations.py` | Разделяем на CPU-базу и GPU-базу (`ARG`), добавляем `gfpgan`, `facexlib` |
|
||||||
|
| `docker-compose.yml` (`photo-ai`) | build `./photo-ai`, `MODEL_PATH`, `MAX_INPUT_PIXELS`, том `photo-ai-models` | Добавляем env `PHOTO_AI_DEVICE`, `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_TILE`, healthcheck |
|
||||||
|
| `worker.js` → `createPhotoEnhanceWorker` | `runAiEnhance(srcKey)` шлёт `image` + `scale=2`, ждёт `image/jpeg` (таймаут 300 с, 3 попытки) | Передаём `model`/`face`/`strength` из `job.params`, разбираем JSON-ответ, 503 ждёт без траты попыток |
|
||||||
|
| `server.js` → `POST /api/entries/:id/photo/enhance-ai` | `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1,'ai','pending',NULL)` | Принимаем `model`/`face`/`face_model`/`strength`, валидируем, пишем в `params` (колонка уже есть) |
|
||||||
|
| `server.js` → `GET /api/photo-jobs/status` | `ai_configured`, `ai_url`, `worker`, `counts` | Добавляем `service.device`, `service.models`, `service.face_models`, `service.ready` |
|
||||||
|
| `server.js` → `PHOTO_JOB_ACTIONS` | белый список действий, `action VARCHAR(20)` | Новые действия `ai_face`, `ai_upscale` в двух местах (валидация + restore) |
|
||||||
|
| `public/js/journal.js` (кнопка «🤖 ИИ») | Ставит задание `action='ai'` | Выбор модели: универсальная / быстрая / лица / обе |
|
||||||
|
| `public/js/worker.js` + `worker.html` | Таблица заданий, `PHOTO_ACTION_LABELS`, сравнение «Было/Стало» | Новые метки, показ модели/устройства/времени обработки |
|
||||||
|
| `settings.html` / `settings.js` | `photo_enhance_engine` (`auto`/`server`/`client`) | Новые настройки: `photo_ai_face_mode`, `photo_ai_device_pref` |
|
||||||
|
|
||||||
|
Текущее поведение, которое **сохраняем байт-в-байт**:
|
||||||
|
`/enhance` с `scale=2` без указания модели → та же картинка, что и сегодня (x2plus, JPEG q92).
|
||||||
|
Это нужно, чтобы старые `photo_jobs` со `params = NULL` и все закешированные превью не изменились.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Модели: что именно добавляем
|
||||||
|
|
||||||
|
### 3.1 Каталог моделей
|
||||||
|
|
||||||
|
| Ключ | Класс | Веса | Размер | Назначение |
|
||||||
|
|------|-------|------|--------|-----------|
|
||||||
|
| `x2plus` | `RRDBNet(scale=2)` | `RealESRGAN_x2plus.pth` | 64 МБ | **текущая**, универсальный апскейл x2, дефолт |
|
||||||
|
| `general-x4v3` | `SRVGGNetCompact(upscale=4)` | `realesr-general-x4v3.pth` | 4.7 МБ | быстрый апскейл x4 + денойз через DNI (`realesr-general-wdn-x4v3.pth`) |
|
||||||
|
| `animevideo-v3` | `SRVGGNetCompact(upscale=4)` | `realesr-animevideov3.pth` | 2.4 МБ | быстрый x4, для скриншотов/иллюстраций |
|
||||||
|
| `gfpgan` | `GFPGANer(arch='clean', channel_multiplier=2)` | `GFPGANv1.4.pth` | 333 МБ | **восстановление лиц**, `bg_upsampler` = выбранный ESRGAN |
|
||||||
|
| `codeformer` | `CodeFormer` | `codeformer.pth` + `detection_Resnet50_Final.pth` + `parsing_parsenet.pth` | 360 МБ + ~110 МБ | **восстановление лиц**, регулируемая сила `fidelity_weight` (`-w`), лучше на сильных искажениях |
|
||||||
|
|
||||||
|
Рекомендация: **GFPGAN v1.4 как основная face-модель** (меньше весов, стабильнее на 4 ГБ VRAM,
|
||||||
|
это модель по умолчанию в апстриме `inference_realesrgan.py` через `--face_enhance`), CodeFormer —
|
||||||
|
опционально, как альтернатива с регулируемой силой. На CPU CodeFormer практически
|
||||||
|
неработоспособен по времени (facexlib + parsing) — оставляем его «только GPU, если включён явно».
|
||||||
|
|
||||||
|
### 3.2 Режимы обработки (`face_mode`)
|
||||||
|
|
||||||
|
| `face_mode` | Что происходит | Модель | Когда использовать |
|
||||||
|
|-------------|----------------|--------|--------------------|
|
||||||
|
| `off` | только апскейл фона, лицо не трогается | ESRGAN | текущее поведение, фон/текстуры |
|
||||||
|
| `face` | апскейл фона + **только лица** вставлены восстановленными (paste-back) | ESRGAN + GFPGAN/CodeFormer | портреты, крупные лица |
|
||||||
|
| `all` | как `face`, плюс мягкий денойз фона | ESRGAN(+wdn) + GFPGAN | зашумлённые снимки с веб-камеры 640×480 |
|
||||||
|
|
||||||
|
Технически GFPGAN работает так: детектирует лица (facexlib/RetinaFace), кропает → восстанавливает
|
||||||
|
→ paste-back в альбом. Если лиц не найдено — возвращает чистый результат `bg_upsampler`: это
|
||||||
|
безопасный no-op и не должно считаться ошибкой.
|
||||||
|
|
||||||
|
### 3.3 Почему face-модель нельзя ставить «всегда»
|
||||||
|
|
||||||
|
1. **Скорость.** Детекция + face-restore + paste-back даёт +40…150 % ко времени. На CPU это
|
||||||
|
разница между ~40 с и ~90 с на фото 640×480.
|
||||||
|
2. **Гарантий нет.** GFPGAN «дорисовывает» лица по приору: на сильно замытых, боковых или
|
||||||
|
закрытых лицах он может сделать человека похожим на другого. Для журнала посещаемости ошибка
|
||||||
|
идентификации недопустима, поэтому face-режим — **явный выбор пользователя**, а не молчаливый
|
||||||
|
дефолт.
|
||||||
|
3. **VRAM.** GFPGAN + x2plus одновременно держат два графа на GPU; на 4 ГБ нужен `tile <= 256`.
|
||||||
|
|
||||||
|
### 3.4 Точные URL весов (скачиваются при первом запуске)
|
||||||
|
|
||||||
|
```
|
||||||
|
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth
|
||||||
|
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth
|
||||||
|
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-wdn-x4v3.pth
|
||||||
|
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-animevideov3.pth
|
||||||
|
https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth
|
||||||
|
https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый файл кладём в `/models/weights/<name>.pth` (том `photo-ai-models`), загрузка через
|
||||||
|
`.tmp` + `os.replace` и проверку минимального размера — иначе оборванная закачка оставит «битые»
|
||||||
|
веса, и сервис будет падать при загрузке.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Автоматический выбор GPU/CPU
|
||||||
|
|
||||||
|
### 4.1 Приоритет выбора (в `app.py` при старте)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. PHOTO_AI_DEVICE=cuda|cpu|auto (env, дефолт auto)
|
||||||
|
2. если auto:
|
||||||
|
a. torch.cuda.is_available() and torch.cuda.device_count() > 0
|
||||||
|
-> device = 'cuda:0', half = True (fp16 быстрее и экономит VRAM)
|
||||||
|
b. иначе если torch.backends.mps.is_available()
|
||||||
|
-> device = 'mps', half = False (Apple Silicon, на случай dev-машины)
|
||||||
|
c. иначе -> device = 'cpu', half = False
|
||||||
|
3. если явно cuda, но cuda недоступна -> НЕ падать: WARN в лог и уйти на cpu
|
||||||
|
(контейнер обязан подниматься даже без GPU — требование отказоустойчивости)
|
||||||
|
4. явно cpu — всегда cpu, даже если GPU есть (для отладки и воспроизводимости)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Прогрев, ленивая загрузка и деградация
|
||||||
|
|
||||||
|
- `load_model()` вызывается в `startup` (сейчас через `asyncio.to_thread`), модели грузятся
|
||||||
|
**лениво по требованию** и кешируются в `MODELS` dict под `threading.Lock`. Первый запрос к
|
||||||
|
новой модели оплачивает её загрузку (x2plus 64 МБ грузится быстрее, чем GFPGAN 333 МБ).
|
||||||
|
- Пока нужная модель не готова, `/enhance` возвращает `503` + `Retry-After: 5`, а `/health`
|
||||||
|
отдаёт `{"ok": true, "ready": false, "loading": ["gfpgan"]}`. Воркер на `503` **не** тратит
|
||||||
|
попытку из `PHOTO_MAX_ATTEMPTS`: он ждёт и повторяет (см. §5.4).
|
||||||
|
- `CUDA out of memory` при инференсе — ловим `RuntimeError`, уменьшаем `tile` вдвое
|
||||||
|
(256 → 128 → 64) и повторяем **один раз**; если снова OOM — переключаемся на `cpu`,
|
||||||
|
инвалидируем модель и повторяем. Если и на CPU не получилось — `500` с понятным текстом.
|
||||||
|
- Прогрев (warm-up) на синтетическом шуме 64×64 сразу после загрузки: убирает «первый запрос в
|
||||||
|
3 раза дольше» и немедленно выявляет OOM.
|
||||||
|
|
||||||
|
### 4.3 Что показывать оператору
|
||||||
|
|
||||||
|
`GET /health` (расширяем, обратно совместимо: поле `ok` остаётся):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"ready": true,
|
||||||
|
"device": "cuda:0",
|
||||||
|
"device_name": "NVIDIA GeForce RTX 3050 ...",
|
||||||
|
"half": true,
|
||||||
|
"tile": 256,
|
||||||
|
"driver": "615.71.09",
|
||||||
|
"cuda": "13.4",
|
||||||
|
"vram_total_mb": 4096,
|
||||||
|
"vram_free_mb": 3780,
|
||||||
|
"models": ["x2plus", "general-x4v3", "animevideo-v3"],
|
||||||
|
"face_models": ["gfpgan", "codeformer"],
|
||||||
|
"loaded": ["x2plus", "gfpgan"],
|
||||||
|
"max_pixels": 4000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`server.js` проксирует это в `GET /api/photo-jobs/status` → `service` и в блок «Статус стека»
|
||||||
|
(`getStackInfo()`, `public/settings.html`), чтобы было видно, на чём реально считает фото-ИИ.
|
||||||
|
|
||||||
|
### 4.4 Docker: как отдать GPU контейнеру
|
||||||
|
|
||||||
|
Два обязательных шага на хосте (сейчас **не выполнены** — `nvidia-ctk` отсутствует):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. NVIDIA Container Toolkit
|
||||||
|
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \
|
||||||
|
sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
||||||
|
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
|
||||||
|
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
|
||||||
|
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||||
|
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
|
||||||
|
sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker
|
||||||
|
|
||||||
|
# 2. Проверка
|
||||||
|
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose делим на два файла, чтобы GPU был **опцией, а не требованием** (по образцу
|
||||||
|
существующего `docker-compose.minio.yml`):
|
||||||
|
|
||||||
|
- `docker-compose.yml` — `photo-ai` без GPU (CPU-образ, как сейчас). Стек поднимается на любой
|
||||||
|
машине.
|
||||||
|
- `docker-compose.gpu.yml` (новый) — override:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
photo-ai:
|
||||||
|
build:
|
||||||
|
context: ./photo-ai
|
||||||
|
args:
|
||||||
|
TORCH_VARIANT: cu124
|
||||||
|
environment:
|
||||||
|
PHOTO_AI_DEVICE: cuda
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск GPU-режима:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `photo-ai` уже запущен в CPU-режиме — сначала `docker compose stop photo-ai`, затем команда
|
||||||
|
выше (иначе Compose ругается на конфликт конфигурации сервиса).
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Изменения по файлам
|
||||||
|
|
||||||
|
### 5.1 `photo-ai/app.py` (переписываем, ~250–300 строк)
|
||||||
|
|
||||||
|
Новая структура:
|
||||||
|
|
||||||
|
```
|
||||||
|
ENV: PHOTO_AI_DEVICE, PHOTO_AI_FACE_MODEL, PHOTO_AI_TILE, PHOTO_AI_MAX_PIXELS,
|
||||||
|
PHOTO_AI_MODELS_DIR, PHOTO_AI_LOAD_ALL, PHOTO_AI_WARMUP, PHOTO_AI_JPEG_QUALITY
|
||||||
|
pick_device() -> (device, half, device_name) # §4.1
|
||||||
|
MODEL_REGISTRY = { 'x2plus': {...}, 'general-x4v3': {...}, 'animevideo-v3': {...} }
|
||||||
|
FACE_REGISTRY = { 'gfpgan': {...}, 'codeformer': {...} }
|
||||||
|
class ModelPool: # Lock, dict, lazy load, OOM-ретрай
|
||||||
|
get_esrgan(name) -> RealESRGANer
|
||||||
|
get_face(name, bg_upsampler) -> GFPGANer | CodeFormer
|
||||||
|
encode_jpeg(out, quality)
|
||||||
|
@app.get('/health') # расширенный контракт §4.3
|
||||||
|
@app.get('/models') # список моделей + устройство
|
||||||
|
@app.post('/enhance') # image, scale, model, face, face_model, strength
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключевые детали реализации:
|
||||||
|
|
||||||
|
- `POST /enhance` принимает те же `image` и `scale`, плюс новые необязательные поля:
|
||||||
|
`model` (дефолт `x2plus`), `face` (`off`|`face`|`all`, дефолт `off`),
|
||||||
|
`face_model` (`gfpgan`|`codeformer`), `strength` (0.0–1.0, только CodeFormer, дефолт 0.7),
|
||||||
|
`jpeg_quality` (70–100, дефолт 92). Неизвестная модель → `400` со списком допустимых.
|
||||||
|
- Полная обратная совместимость: без `model`/`face` → путь «x2plus, JPEG q92», как сегодня.
|
||||||
|
- `face != off` → `face_enhancer.enhance(img, has_aligned=False, only_center_face=False,
|
||||||
|
paste_back=True)`; в ответ добавляем `faces_found`.
|
||||||
|
- **Два формата ответа**: JSON (`{ok, image_base64, model, face, face_model, faces_found,
|
||||||
|
device, elapsed_ms, warnings}`) при `Accept: application/json` — новый путь для воркера;
|
||||||
|
сырой `image/jpeg` без заголовка — текущее поведение (совместимость с ручными `curl`).
|
||||||
|
- `upsampler.enhance()` уже делает тайлинг сам — `tile`/`tile_pad=10` остаются, `tile` берём из env.
|
||||||
|
- `half=True` только при CUDA; `dni_weight` — только для `general-x4v3` при `denoise_strength != 1`.
|
||||||
|
- Расширения определяем по имени файла, а не по `UploadFile.content_type` (браузер и `FormData`
|
||||||
|
в `worker.js` шлют `image/jpeg` для любого исходника — сейчас это уже так, сохраняем).
|
||||||
|
|
||||||
|
### 5.2 `photo-ai/Dockerfile`
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.10-slim
|
||||||
|
ARG TORCH_VARIANT=cpu # cpu | cu124
|
||||||
|
ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `TORCH_VARIANT=cpu` → `torch torchvision --index-url .../whl/cpu` (как сейчас);
|
||||||
|
`cu124` → тот же `pip` с `.../whl/cu124`. Образ один, вариант — аргумент сборки.
|
||||||
|
- Добавить `gfpgan==1.3.8` и `facexlib==0.3.0` (CodeFormer — из TencentARC, пакет/вендоринг).
|
||||||
|
**Проверить доступность пакетов в зеркале pip заранее** — это риск сборки, если недоступны,
|
||||||
|
вендорим исходники в `photo-ai/vendor/` и копируем каталог в образ.
|
||||||
|
- `libgl1 libglib2.0-0` уже есть — facexlib их требует.
|
||||||
|
- Патч `basicsr/data/degradations.py` (`torchvision.transforms.functional_tensor` → `functional`)
|
||||||
|
**сохранить** — без него basicsr падает на torch >= 2.0.
|
||||||
|
- `ENV PHOTO_AI_MODELS_DIR=/models`; старый `MODEL_PATH` продолжаем читать как алиас, чтобы
|
||||||
|
существующий `.env`/том не сломался.
|
||||||
|
- Размер образа: CPU ~1.2 ГБ → ~2.5 ГБ; cu124 ~6–7 ГБ. Учитывать при `--build`.
|
||||||
|
- Тома: `photo-ai-models` уже есть — все веса в `/models/weights/*.pth`.
|
||||||
|
|
||||||
|
### 5.3 `docker-compose.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
photo-ai:
|
||||||
|
environment:
|
||||||
|
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-auto}
|
||||||
|
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
|
||||||
|
PHOTO_AI_TILE: ${PHOTO_AI_TILE:-256}
|
||||||
|
PHOTO_AI_MAX_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
|
||||||
|
PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0}
|
||||||
|
PHOTO_AI_JPEG_QUALITY: ${PHOTO_AI_JPEG_QUALITY:-92}
|
||||||
|
MODEL_PATH: /models/RealESRGAN_x2plus.pth
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3).status==200 else 1)\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 300s
|
||||||
|
```
|
||||||
|
|
||||||
|
`start_period: 300s` — загрузка x2plus + GFPGAN на CPU занимает минуты; короткий период даст
|
||||||
|
`unhealthy` на старте. `PHOTO_AI_LOAD_ALL=1` предзагружает все модели (для GPU-сервера с запасом
|
||||||
|
RAM), по умолчанию `0` — ленивая загрузка.
|
||||||
|
|
||||||
|
### 5.4 `worker.js` (функция `createPhotoEnhanceWorker`)
|
||||||
|
|
||||||
|
- `runAiEnhance(srcKey, params)`:
|
||||||
|
- читает `params.model`, `params.face`, `params.face_model`, `params.strength`;
|
||||||
|
- отправляет их вместе с `image` и `scale`; ставит `Accept: application/json`, разбирает
|
||||||
|
JSON-ответ, декодирует base64 в буфер;
|
||||||
|
- если сервис вернул `image/jpeg` (старая версия photo-ai) — работает как сейчас, без ошибок;
|
||||||
|
- `503` (`Retry-After`) обрабатывает отдельно: `sleep` 10 с, `return false` — **без** инкремента
|
||||||
|
`attempts` (мягкий повтор, задание не сгорает);
|
||||||
|
- `AbortError` от `AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS)` → сообщение
|
||||||
|
«ИИ-сервис не ответил за N с» (уже ошибка с попытками);
|
||||||
|
- в аудит пишет модель/устройство/время: `logAudit(null, 'photo.job.preview', { ..., model,
|
||||||
|
face, device, elapsed_ms })`.
|
||||||
|
- `processOne`: `job.action === 'ai' || job.action === 'ai_face'` → AI-путь; `enhance` → sharp.
|
||||||
|
Если `params` пусты, `action='ai_face'` даёт дефолт `face='face'`.
|
||||||
|
- `CONFIG` воркера дополняем `face_model`, `default_model`, `face_timeout_ms` — они попадают в
|
||||||
|
`GET /api/photo-jobs/status.worker.config`.
|
||||||
|
- Для face-режима на CPU вводим отдельный, больший таймаут: `PHOTO_AI_FACE_TIMEOUT_MS`
|
||||||
|
(дефолт 600 000) вместо 300 000.
|
||||||
|
|
||||||
|
|
||||||
|
### 5.5 `server.js`
|
||||||
|
|
||||||
|
1. `POST /api/entries/:id/photo/enhance-ai` (строка 5114) — тело
|
||||||
|
`{ model, face, face_model, strength }`:
|
||||||
|
- `model` ∈ `['x2plus', 'general-x4v3', 'animevideo-v3']`;
|
||||||
|
- `face` ∈ `['off', 'face', 'all']`;
|
||||||
|
- `face_model` ∈ `['gfpgan', 'codeformer']`;
|
||||||
|
- `strength` — число 0…1, только для CodeFormer;
|
||||||
|
- `params = JSON.stringify({ model, face, face_model, strength })` в `photo_jobs.params`;
|
||||||
|
- `action = face === 'off' ? 'ai' : 'ai_face'`;
|
||||||
|
- `400` при невалидных значениях; пустое тело → сегодняшнее поведение (`params = NULL`,
|
||||||
|
`action = 'ai'`). Валидация — инлайн-хелперами (`optInt`) и явными списками, как в
|
||||||
|
`PUT /api/settings`.
|
||||||
|
2. `PHOTO_JOB_ACTIONS` — добавить `'ai_face'`, `'ai_upscale'`. Схема не меняется
|
||||||
|
(`action VARCHAR(20)`), но белый список используется в двух местах: `ensurePhotoJobsTable()`
|
||||||
|
(~1238–1256) и при разборе restore-данных (~2259) — обновить **оба**.
|
||||||
|
3. `GET /api/photo-jobs/status` (строка 5782) — прокинуть `service.device`,
|
||||||
|
`service.device_name`, `service.ready`, `service.vram_free_mb`, `service.models`,
|
||||||
|
`service.face_models` из `GET /health` photo-ai (таймаут 5 с, по образцу `aiHealthCheck()`).
|
||||||
|
Запрос делать без падения: photo-ai недоступен → `service = { reachable: false }`.
|
||||||
|
4. Новый `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` для оператора.
|
||||||
|
В `getStackInfo()` добавить блок `photo_ai` (`device`, `device_name`, `vram_total_mb`,
|
||||||
|
`models`) — рендерится в «Статус стека» на `public/settings.html`.
|
||||||
|
5. Настройки — валидация рядом со строкой 1710:
|
||||||
|
- `photo_ai_face_mode` ∈ `['off', 'face', 'all']`, дефолт `off`;
|
||||||
|
- `photo_ai_device_pref` ∈ `['auto', 'cuda', 'cpu']`, дефолт `auto` — **только для UI и
|
||||||
|
документации**: фактическое устройство определяет контейнер через env, UI показывает,
|
||||||
|
совпадает ли желаемое с фактическим (если нет — подсветить).
|
||||||
|
- дефолты дописать в `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`)
|
||||||
|
и в `keys`/`defaults` `/api/public-settings` (строка 1663–1664);
|
||||||
|
- `photo_ai_enabled` там уже отдаётся (строка 1677) — сохранить.
|
||||||
|
6. `worker.js` вызывается с новыми параметрами: в `createPhotoEnhanceWorker({...})` (строка 6045)
|
||||||
|
добавить `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`, `defaultFaceModel: PHOTO_AI_FACE_MODEL`.
|
||||||
|
7. Предупреждения сервиса (`warnings`, например «вход меньше 320×320») сохранять в аудит, чтобы
|
||||||
|
оператор понимал, что face-режим не сработал не из-за ошибки.
|
||||||
|
|
||||||
|
### 5.6 Фронтенд
|
||||||
|
|
||||||
|
- `public/js/journal.js` (кнопка «🤖 ИИ», ~строка 620) — рядом dropdown: «Универсально (x2)»,
|
||||||
|
«Быстро (x4)», «Лица (GFPGAN)», «Лица + фон». Значение уходит в
|
||||||
|
`POST /api/entries/:id/photo/enhance-ai` телом `{ model, face, face_model }`. Дефолт — из
|
||||||
|
`photo_ai_face_mode` (публичные настройки уже читаются на этой странице).
|
||||||
|
- `public/js/worker.js` — `PHOTO_ACTION_LABELS` дополнить (`ai_face`: «ИИ + лица»,
|
||||||
|
`ai_upscale`: «ИИ-апскейл»); в модалке сравнения «Было/Стало» показать `model`, `device`,
|
||||||
|
`faces_found`, `elapsed_ms`. Новых колонок в БД не нужно — данные берём из `params` и аудита.
|
||||||
|
- `public/settings.html` + `public/js/settings.js` — блок «Фото-ИИ»: селект face-режима, селект
|
||||||
|
устройства («желаемое» + строка «фактическое»), read-only статус (`device_name`, VRAM, список
|
||||||
|
моделей) из `/api/photo-jobs/status`. Новые поля добавить в `DIRTY_FIELDS` (строка 1
|
||||||
|
`settings.js`) и в сборку payload (строка ~739).
|
||||||
|
- `public/js/audit.js` — новые коды действий аудита прописать в словарь меток, иначе в UI будет
|
||||||
|
сырой код (`photo.job.preview` уже есть, при добавлении `photo.job.ai` — добавить и там).
|
||||||
|
|
||||||
|
### 5.7 Документация и тесты
|
||||||
|
|
||||||
|
- `AGENTS.md` — раздел про photo-ai: реестр моделей, device-политика, новые env, GPU-override,
|
||||||
|
обновлённый контракт `/enhance` и `/health`.
|
||||||
|
- `README.md` — установка NVIDIA Container Toolkit, команда запуска с `docker-compose.gpu.yml`,
|
||||||
|
проверка `GET /api/photo-jobs/status` → `service.device`.
|
||||||
|
- `.env.example` — новые переменные с комментариями (см. §7).
|
||||||
|
- `api.smoketest.js` — добавить проверки:
|
||||||
|
- `POST /api/entries/:id/photo/enhance-ai` с `model: 'нет такой'` → `400`;
|
||||||
|
- `face: 'face'` без `PHOTO_AI_URL` → `503`;
|
||||||
|
- `GET /api/photo-jobs/status` содержит `service` (или `ai_configured: false`).
|
||||||
|
Это соответствует правилу из `AGENTS.md`: контракт авторизации и API фиксируется в smoke-тесте
|
||||||
|
в том же изменении.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Порядок внедрения (этапы)
|
||||||
|
|
||||||
|
Каждый этап заканчивается проверяемым результатом и не ломает предыдущий.
|
||||||
|
|
||||||
|
### Этап 0. Подготовка (0.5 дня)
|
||||||
|
- Установить NVIDIA Container Toolkit, проверить `docker run --gpus all ... nvidia-smi`.
|
||||||
|
- Зафиксировать текущее состояние `.env` (`PHOTO_AI_URL=` пусто или `http://photo-ai:8080`).
|
||||||
|
- Сохранить эталон «до»: 3–5 фото (портрет, групповое, без лиц, зашумлённое), прогнать
|
||||||
|
`curl -F image=@photo.jpg -F scale=2 http://localhost:8080/enhance`.
|
||||||
|
|
||||||
|
**Приёмка:** `nvidia-smi` внутри контейнера видит RTX 3050; эталонные JPEG сохранены для
|
||||||
|
сравнения на следующих этапах.
|
||||||
|
|
||||||
|
### Этап 1. `app.py`: реестр моделей + автовыбор device (1–1.5 дня)
|
||||||
|
- `pick_device()`, `ModelPool`, расширенный `/health`, ленивая загрузка x2plus, warm-up.
|
||||||
|
- Старый `/enhance` по поведению не меняется (проверить визуально и по размеру файла).
|
||||||
|
|
||||||
|
**Приёмка:** `/health` отдаёт `device`, `device_name`, `half`; `PHOTO_AI_DEVICE=cpu` при рабочей
|
||||||
|
CUDA даёт `cpu`; `PHOTO_AI_DEVICE=cuda` без toolkit даёт `cpu` + WARN, сервис поднимается.
|
||||||
|
|
||||||
|
### Этап 2. Dockerfile + compose (0.5–1 день)
|
||||||
|
- `ARG TORCH_VARIANT`, `docker-compose.gpu.yml`, healthcheck, новые env, алиас `MODEL_PATH`.
|
||||||
|
|
||||||
|
**Приёмка:** CPU- и GPU-сборка стартуют; `/api/photo-jobs/status` показывает
|
||||||
|
`service.device = "cuda:0"` в GPU-режиме и `"cpu"` в CPU-режиме; при пустом `PHOTO_AI_URL`
|
||||||
|
приложение полностью работает без photo-ai.
|
||||||
|
|
||||||
|
### Этап 3. GFPGAN — face-режим (1–2 дня)
|
||||||
|
- `FACE_REGISTRY`, поля `face`/`face_model`/`strength` в `/enhance`, JSON-ответ, `faces_found`,
|
||||||
|
OOM-ретрай и fallback на CPU.
|
||||||
|
|
||||||
|
**Приёмка:** на тестовом портрете 640×480 в режиме `face` лицо резче, `faces_found = 1`;
|
||||||
|
на фото без лиц — `faces_found = 0` и результат не хуже, чем `x2plus`; искусственный OOM
|
||||||
|
(`tile=1024` на 4 ГБ) деградирует до CPU без падения сервиса.
|
||||||
|
|
||||||
|
### Этап 4. Воркер и API (1 день)
|
||||||
|
- `worker.js`: `params` → модель/face, JSON-разбор, `503`-ожидание без траты попыток, аудит.
|
||||||
|
- `server.js`: валидация `params`, `action = 'ai_face'`, `service` в статусе, новые настройки.
|
||||||
|
|
||||||
|
**Приёмка:** задание через API с `face='face'` доходит до `done`, параметры сохранены в
|
||||||
|
`photo_jobs.params`, в аудите — модель/устройство/время; остановка photo-ai на лету даёт задание,
|
||||||
|
которое дообработается после возврата сервиса (без `error`).
|
||||||
|
|
||||||
|
### Этап 5. Фронтенд (1 день)
|
||||||
|
- Выбор модели в журнале, статус устройства в настройках, новые метки в воркере.
|
||||||
|
|
||||||
|
**Приёмка:** из журнала доступны все 4 варианта; после постановки видно «В очереди», затем
|
||||||
|
сравнение «Было/Стало»; в настройках показано фактическое устройство и VRAM.
|
||||||
|
|
||||||
|
### Этап 6. Docs + smoke (0.5 дня)
|
||||||
|
- `AGENTS.md`, `README.md`, `.env.example`, `api.smoketest.js`.
|
||||||
|
|
||||||
|
**Приёмка:** `node api.smoketest.js` проходит; тесты валидации возвращают `400`; документация
|
||||||
|
совпадает с кодом.
|
||||||
|
|
||||||
|
**Итого:** ~5–7 рабочих дней. Этапы 1–3 дают ценность уже без фронтенда (ручные вызовы `curl`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Новые переменные окружения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# === Фото-ИИ (Real-ESRGAN + восстановление лиц) ===
|
||||||
|
# Адрес сервиса; пусто = фото-ИИ выключен (кнопка «ИИ» недоступна)
|
||||||
|
PHOTO_AI_URL=
|
||||||
|
# Максимум входных пикселей (даунскейл перед обработкой)
|
||||||
|
PHOTO_AI_MAX_PIXELS=4000000
|
||||||
|
# Устройство: auto | cuda | cpu. auto = CUDA, если доступна, иначе CPU
|
||||||
|
PHOTO_AI_DEVICE=auto
|
||||||
|
# Тайл для апскейла: меньше = меньше VRAM, но медленнее (256 на 4 ГБ, 512+ при 8 ГБ+)
|
||||||
|
PHOTO_AI_TILE=256
|
||||||
|
# Модель лиц: gfpgan | codeformer | none
|
||||||
|
PHOTO_AI_FACE_MODEL=gfpgan
|
||||||
|
# Предзагружать все модели при старте (1 — да, нужно больше RAM/VRAM)
|
||||||
|
PHOTO_AI_LOAD_ALL=0
|
||||||
|
# Режим лиц по умолчанию для UI: off | face | all
|
||||||
|
PHOTO_AI_FACE_MODE=off
|
||||||
|
# Качество JPEG результата (70–100)
|
||||||
|
PHOTO_AI_JPEG_QUALITY=92
|
||||||
|
# Отдельный таймаут для face-режима, мс (на CPU медленно)
|
||||||
|
PHOTO_AI_FACE_TIMEOUT_MS=600000
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Риски и как их закрываем
|
||||||
|
|
||||||
|
| Риск | Вероятность | Митигация |
|
||||||
|
|------|-------------|-----------|
|
||||||
|
| `nvidia-container-toolkit` не установлен / политика хоста запрещает | средняя | GPU — отдельный override-файл; CPU-путь остаётся дефолтом и полностью рабочим; в `/health` видно фактическое устройство |
|
||||||
|
| 4 ГБ VRAM не хватает для GFPGAN + x2plus | высокая | `tile=256` по умолчанию, авто-снижение до 128/64 при OOM, `half=True` только на CUDA, при повторном OOM — fallback на CPU |
|
||||||
|
| GFPGAN «портит» лица (артефакты идентичности) | средняя | face-режим только по явному выбору; результат попадает в `photo_jobs.after_path` и **не применяется автоматически** (нужно нажать «Применить»), история и «Вернуть оригинал» сохраняются |
|
||||||
|
| Долгая загрузка весов (333 МБ) на первом запросе | высокая | ленивая загрузка + `503`/`Retry-After` вместо ошибки; `PHOTO_AI_LOAD_ALL=1` для прогрева; том `photo-ai-models` сохраняет веса между перезапусками; `start_period: 300s` в healthcheck |
|
||||||
|
| Сборка ломается на `pip install gfpgan/facexlib` (пакета нет в зеркале) | средняя | **проверить доступность пакетов до начала работ**; при отсутствии — вендорить исходники в `photo-ai/vendor/` и копировать каталог в образ |
|
||||||
|
| Рост образа до ~6–7 ГБ (cu124) | средняя | CPU-образ по умолчанию, GPU-образ собирается отдельно; на `/home` свободно 59 ГБ |
|
||||||
|
| CPU-инференс с GFPGAN медленнее таймаута воркера | средняя | отдельный `PHOTO_AI_FACE_TIMEOUT_MS` (600 с) для face-режима; в UI предупреждение «на CPU медленно»; для слабых машин — `off` |
|
||||||
|
| Регресс текущего `/enhance` | низкая | контракт по умолчанию не меняется; эталонное сравнение на этапах 1–2; старые `photo_jobs.params = NULL` продолжают работать |
|
||||||
|
| Нехватка RAM (15 ГБ, занято ~9 ГБ) при загрузке моделей на CPU | средняя | `PHOTO_AI_LOAD_ALL=0`, кеш моделей с вытеснением (LRU, максимум 2), контроль через `docker stats` |
|
||||||
|
| Воркер зацикливается на «вечно недоступном» сервисе | низкая | при `503`/`unreachable` — экспоненциальный `sleep`, лимит мягких повторов (например 60) → затем одна честная ошибка с понятным текстом |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Критерии готовности
|
||||||
|
|
||||||
|
1. `photo-ai` стартует и на CPU-хосте, и с CUDA, определяя устройство автоматически; `/health`
|
||||||
|
сообщает фактическое устройство, имя GPU, VRAM, доступные и загруженные модели.
|
||||||
|
2. Существующий сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему.
|
||||||
|
3. Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
|
||||||
|
4. Задание с face-моделью проходит полный цикл `pending → processing → done`; результат виден в
|
||||||
|
сравнении «Было/Стало», применим вручную и откатывается.
|
||||||
|
5. Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` **не** ломают приложение:
|
||||||
|
`PHOTO_AI_URL=` пусто → кнопка «ИИ» недоступна; сервис упал → задания пережидают (`503`), не
|
||||||
|
сжигая попытки; пустой `.env` полностью совместим.
|
||||||
|
6. `node api.smoketest.js` проходит; `AGENTS.md`, `README.md`, `.env.example` описывают новые
|
||||||
|
переменные и GPU-запуск.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Быстрые команды для проверки после реализации
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Статус устройства и моделей
|
||||||
|
docker compose exec -T app node -e "fetch(process.env.PHOTO_AI_URL+'/health').then(r=>r.json()).then(console.log)"
|
||||||
|
curl -s -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/photo-jobs/status | python3 -m json.tool
|
||||||
|
|
||||||
|
# Прямой вызов с лицами (ручная проверка)
|
||||||
|
curl -s -X POST http://localhost:8080/enhance \
|
||||||
|
-F image=@photo.jpg -F scale=2 -F model=x2plus -F face=face -F face_model=gfpgan \
|
||||||
|
-H 'Accept: application/json' | python3 -c "import json,sys,base64; d=json.load(sys.stdin); open('out.jpg','wb').write(base64.b64decode(d['image_base64'])); print({k:v for k,v in d.items() if k!='image_base64'})"
|
||||||
|
|
||||||
|
# CPU-режим принудительно
|
||||||
|
docker compose stop photo-ai
|
||||||
|
PHOTO_AI_DEVICE=cpu docker compose -f docker-compose.yml up -d --build photo-ai
|
||||||
|
|
||||||
|
# GPU-режим
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||||
|
|
||||||
|
# Проверка обратной совместимости (без model/face — как раньше)
|
||||||
|
curl -s -X POST http://localhost:8080/enhance -F image=@photo.jpg -F scale=2 -o old_behavior.jpg
|
||||||
|
|
||||||
|
# Смоук
|
||||||
|
node api.smoketest.js
|
||||||
|
```
|
||||||
|
|
||||||
@@ -46,7 +46,8 @@
|
|||||||
| ENT-5 | Trash view: list deleted entries, restore, permanent delete | Must |
|
| ENT-5 | Trash view: list deleted entries, restore, permanent delete | Must |
|
||||||
| ENT-6 | Pagination (limit/offset) + total count | Must |
|
| ENT-6 | Pagination (limit/offset) + total count | Must |
|
||||||
| ENT-7 | Anti-spam: min interval between entries per student (configurable, default 30 min) | Must |
|
| ENT-7 | Anti-spam: min interval between entries per student (configurable, default 30 min) | Must |
|
||||||
| ENT-8 | Files attached to entry: upload (max 10 files, 10 MB each, 30 MB total), download by token | Must |
|
| ENT-8 | Files attached to entry: upload (max 10 files, per-file and total size limits from `UPLOAD_FILE_LIMIT_MB`/`UPLOAD_TOTAL_LIMIT_MB`), download by token | Must |
|
||||||
|
| ENT-9 | Video attachments: `mp4`/`m4v`/`webm`/`ogv` playable inline in the journal with seeking (Range), other video formats download-only | Should |
|
||||||
|
|
||||||
### 2.4 Files Management (Centralized)
|
### 2.4 Files Management (Centralized)
|
||||||
| ID | Requirement | Priority |
|
| ID | Requirement | Priority |
|
||||||
@@ -175,7 +176,7 @@ branches
|
|||||||
| GET | `/api/files` | Admin | All files (filters) |
|
| GET | `/api/files` | Admin | All files (filters) |
|
||||||
| GET | `/api/files/detached` | Admin | Detached files |
|
| GET | `/api/files/detached` | Admin | Detached files |
|
||||||
| POST | `/api/files/:id/detach` | Admin | Detach file |
|
| POST | `/api/files/:id/detach` | Admin | Detach file |
|
||||||
| GET | `/api/files/:token` | Public | Download file by token |
|
| GET | `/api/files/:token` | Public | Download file by token; `?play=1` serves `mp4`/`m4v`/`webm`/`ogv` inline with `Range` support |
|
||||||
| GET | `/api/groups` | Public | List groups |
|
| GET | `/api/groups` | Public | List groups |
|
||||||
| POST/PUT/DELETE | `/api/groups` | Admin | CRUD groups |
|
| POST/PUT/DELETE | `/api/groups` | Admin | CRUD groups |
|
||||||
| GET/POST | `/api/groups/:id/photos` | Admin | Group photo chronicle |
|
| GET/POST | `/api/groups/:id/photos` | Admin | Group photo chronicle |
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
- **Воспитанники** — справочник с привязкой к группам
|
- **Воспитанники** — справочник с привязкой к группам
|
||||||
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
|
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
|
||||||
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
|
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
|
||||||
|
- **Отчёты о занятиях** — тьютор описывает, что прошли на занятии; галочка в окне отчёта отправляет текст модели, которая сверяет его с шаблоном делового сообщения (настраивается в «Настройках» → «Шаблон отчёта»): совпал — остаётся как есть, не совпал — переписывается в деловом виде. Обработка идёт в фоне, оригинал тьютора сохраняется, доступна история версий с восстановлением
|
||||||
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
|
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
|
||||||
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
|
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
|
||||||
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
|
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
|
||||||
|
- **Уведомления** — системные события (новые записи журнала, обработка фото нейросетью, ошибки авто-проверки текста, блокировки IP, бэкапы) собираются в «колокольчике» и на странице «Уведомления»; набор событий включается/выключается в «Настройках» → «Уведомления»
|
||||||
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
|
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
|
||||||
|
|
||||||
## Технологии
|
## Технологии
|
||||||
@@ -118,6 +120,86 @@ REDIS_PASSWORD=случайная-длинная-строка
|
|||||||
|
|
||||||
Имя узла Tailscale задаётся в `docker-compose.yml` (`tailscale.hostname`, по умолчанию `whatido`).
|
Имя узла Tailscale задаётся в `docker-compose.yml` (`tailscale.hostname`, по умолчанию `whatido`).
|
||||||
|
|
||||||
|
## ИИ-улучшение фото (photo-ai)
|
||||||
|
|
||||||
|
Сервис `photo-ai` (Real-ESRGAN + GFPGAN) поднимается вместе со стеком и **включён по умолчанию**:
|
||||||
|
`PHOTO_AI_URL` в `docker-compose.yml` равен `http://photo-ai:8080`, кнопка «🤖 ИИ» активна,
|
||||||
|
а задания обрабатывает фоновый воркер. Базовая сборка работает на CPU, GPU не требуется.
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `PHOTO_AI_URL` | `http://photo-ai:8080` | Адрес сервиса. **Пустое значение = сервис выключен**: кнопка «🤖 ИИ» скрыта, `POST /api/entries/:id/photo/enhance-ai` отвечает `503`, приложение при этом полностью работоспособно |
|
||||||
|
| `PHOTO_AI_MAX_PIXELS` | `4000000` | Максимум пикселей входного изображения, вход большего размера уменьшается |
|
||||||
|
| `PHOTO_AI_DEVICE` | `auto` | Устройство инференса: `auto` (CUDA, если контейнеру выдан GPU, иначе CPU), `cuda`, `cpu`. Явный `cuda` без CUDA не роняет сервис: WARN в лог и работа на CPU |
|
||||||
|
| `PHOTO_AI_TILE` | `256` | Размер тайла инференса (`0` — без тайлов): меньше тайл — меньше памяти, но медленнее |
|
||||||
|
| `PHOTO_AI_FACE_MODEL` | `gfpgan` | Модель восстановления лиц: `gfpgan`; `codeformer` доступен только при вендоринге модуля в `photo-ai/vendor` |
|
||||||
|
| `PHOTO_AI_LOAD_ALL` | `0` | Загружать все модели при старте (`1`) или лениво по требованию (`0`) |
|
||||||
|
| `PHOTO_AI_JPEG_QUALITY` | `92` | Качество JPEG результата, 70..100 |
|
||||||
|
| `PHOTO_AI_FACE_TIMEOUT_MS` | `600000` | Таймаут заданий с восстановлением лиц (мс) |
|
||||||
|
| `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | Сколько раз задание ждёт недоступный сервис, не увеличивая счётчик попыток; после исчерпания — честная ошибка |
|
||||||
|
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | Первая пауза перед мягким повтором |
|
||||||
|
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | Потолок паузы (задержка растёт вдвое) |
|
||||||
|
|
||||||
|
### Запуск на NVIDIA GPU
|
||||||
|
|
||||||
|
GPU не обязателен: без него сервис работает на CPU. Чтобы включить GPU-вариант, нужен драйвер NVIDIA
|
||||||
|
и NVIDIA Container Toolkit.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Toolkit (один раз, требует sudo)
|
||||||
|
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
||||||
|
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
|
||||||
|
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
|
||||||
|
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||||
|
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
|
||||||
|
|
||||||
|
# 2. GPU-образ (для устройств с поддержкой CDI спеку генерирует сам toolkit)
|
||||||
|
sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
|
||||||
|
sudo systemctl restart docker
|
||||||
|
|
||||||
|
# 3. Запуск
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||||
|
curl http://127.0.0.1:8081/health
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.gpu.yml` собирает отдельный тег `whatido-photo-ai:cu126` (torch из индекса `cu126` —
|
||||||
|
те же версии, что и в CPU-образе, отличаются только CUDA-библиотеки), поэтому CPU-образ
|
||||||
|
`whatido-photo-ai:latest` не перетирается и переключение обратно — обычный `docker compose up -d photo-ai`.
|
||||||
|
|
||||||
|
GPU выдаётся контейнеру через CDI (`device_ids: nvidia.com/gpu=all`), поэтому править
|
||||||
|
`/etc/docker/daemon.json` и перезапускать демон не нужно. Если nvidia-runtime уже зарегистрирован в
|
||||||
|
демоне (`nvidia-ctk runtime configure --runtime=docker`), в оверрайде можно заменить это на
|
||||||
|
классическое резервирование `driver: nvidia, count: 1` — результат тот же.
|
||||||
|
|
||||||
|
Проверка результата: в `/health` должны быть `device: cuda:0`, `half: true`, непустые
|
||||||
|
`vram_total_mb`/`vram_free_mb`. На 4 ГБ (например, RTX 3050 Laptop) реально держатся одновременно
|
||||||
|
`x2plus` и `gfpgan`: `vram_free_mb` после двух моделей — около 100–300 МБ, поэтому `PHOTO_AI_TILE`
|
||||||
|
оставьте небольшим (`256`), а `PHOTO_AI_LOAD_ALL=1` на 4 ГБ лучше не включать — предзагрузка всех
|
||||||
|
моделей подряд исчерпает VRAM. Если памяти не хватило, сервис сам проходит лестницу тайлов
|
||||||
|
(`PHOTO_AI_TILE` → /2 → /4), затем переключается на CPU и возвращает результат с предупреждением
|
||||||
|
в `warnings` — задание при этом не падает.
|
||||||
|
|
||||||
|
Порт `8081` на `127.0.0.1` — только loopback хоста, наружу ничего не публикуется (хостовый `8080` занят
|
||||||
|
`text-corrector`). Ручные проверки сервиса:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8081/health
|
||||||
|
curl -F "image=@photo.jpg" -F "scale=2" http://127.0.0.1:8081/enhance -o out.jpg
|
||||||
|
curl -H "Accept: application/json" -F "image=@photo.jpg" -F "face=face" http://127.0.0.1:8081/enhance \
|
||||||
|
| python3 -c "import json,sys; d=json.load(sys.stdin); print({k: d[k] for k in ('device','faces_found','elapsed_ms','warnings')})"
|
||||||
|
```
|
||||||
|
|
||||||
|
Состояние сервиса и воркера — в `GET /api/photo-jobs/status` (admin): `service` отдаёт health фото-сервиса
|
||||||
|
(`configured`, `reachable`, `device`, `device_name`, `half`, `tile`, `vram_total_mb`, `vram_free_mb`,
|
||||||
|
`models`, `face_models`, `loaded`, `latency_ms`, `error`), `ai_configured` — задан ли `PHOTO_AI_URL`,
|
||||||
|
`worker` — состояние очереди и конфигурация воркера.
|
||||||
|
|
||||||
|
Параметры `/enhance`: `image` (файл), `scale` (`2`..`4`), `model` (`x2plus`|`general-x4v3`|`animevideo-v3`),
|
||||||
|
`face` (`off`|`face`|`all`), `face_model` (`gfpgan`|`codeformer`), `strength` (`0..1`, только CodeFormer),
|
||||||
|
`jpeg_quality` (`70..100`). По умолчанию отдаётся сырой `image/jpeg`; заголовок
|
||||||
|
`Accept: application/json` переключает на JSON с `image_base64`, `faces_found`, `device`, `elapsed_ms`
|
||||||
|
и `warnings` (малое разрешение входа, лица не найдены, не хватило памяти, CodeFormer на CPU).
|
||||||
|
|
||||||
## Публичный доступ через Tailscale
|
## Публичный доступ через Tailscale
|
||||||
|
|
||||||
Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете.
|
Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете.
|
||||||
@@ -399,6 +481,27 @@ node redis.selftest.js # юнит-тесты redis.js
|
|||||||
node api.smoketest.js # сквозная проверка API (нужен запущенный стек)
|
node api.smoketest.js # сквозная проверка API (нужен запущенный стек)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Уведомления
|
||||||
|
|
||||||
|
Система уведомлений — журнал событий (`notifications`) с отметками прочтения на пользователя (`notification_reads`) плюс каталог типов событий `NOTIFY_TYPES` в `server.js`.
|
||||||
|
|
||||||
|
| Тип | Событие | Кому видно |
|
||||||
|
|-----|---------|------------|
|
||||||
|
| `entry.new` | новая запись в журнале (форма ученика или ручное добавление) | филиал группы |
|
||||||
|
| `entry.ai.corrected` | ИИ исправил текст (по умолчанию выключено) | филиал группы |
|
||||||
|
| `entry.ai.error` | авто-проверка текста не удалась | филиал группы |
|
||||||
|
| `photo.job.done` | фото обработано нейросетью или сервером | филиал группы |
|
||||||
|
| `photo.job.error` | очередь обработки фото исчерпала попытки | филиал группы |
|
||||||
|
| `ip.ban` | IP отправлен в бан (авто или вручную) | только админ |
|
||||||
|
| `backup.restore` | восстановление из бэкапа | только админ |
|
||||||
|
| `backup.create` | создан архив бэкапа (по умолчанию выключено) | только админ |
|
||||||
|
|
||||||
|
Где видно: «колокольчик» в боковом меню (панель последних событий, бейдж непрочитанных, опциональные уведомления браузера) и страница `notifications.html` (фильтр «непрочитанные», отметка «прочитано», удаление и полная очистка для админа). Новые события приходят в реальном времени по SSE (`GET /api/notifications/stream`), транспорт — Redis pub/sub с in-memory fallback.
|
||||||
|
|
||||||
|
Что настраивается в «Настройках» → «Уведомления» (ключи таблицы `settings`): общий выключатель `notify_enabled`, срок хранения `notify_retention_days` (1–365 дней, старые уведомления удаляются ежечасно) и отдельный переключатель `notify_<тип>` для каждого события. Там же кнопка тестового уведомления.
|
||||||
|
|
||||||
|
Видимость: администратор видит все уведомления, остальные — только события своего филиала (или без филиала) и никогда — события с пометкой `admin_only`.
|
||||||
|
|
||||||
## Бэкапы
|
## Бэкапы
|
||||||
|
|
||||||
|
|
||||||
@@ -425,7 +528,8 @@ node api.smoketest.js # сквозная проверка API (нужен
|
|||||||
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям.
|
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям.
|
||||||
- **CORS отключён** — кросс-доменные запросы к API запрещены.
|
- **CORS отключён** — кросс-доменные запросы к API запрещены.
|
||||||
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
|
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
|
||||||
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
|
- **Загрузки** ограничены: суммарно на запись и на файл — лимиты из `UPLOAD_TOTAL_LIMIT_MB` / `UPLOAD_FILE_LIMIT_MB` (по умолчанию 200 МБ и 50 МБ); заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
|
||||||
|
- **Видеофайлы** (`.mp4`, `.m4v`, `.webm`, `.ogv`) играются прямо в журнале: `GET /api/files/:token?play=1` отдаёт файл **inline** с `Accept-Ranges: bytes` и поддержкой `Range` (`206`), поэтому перемотка работает без скачивания целиком. Остальные форматы (`.mov`, `.mkv`, `.avi` и пр.) браузер не играет — они остаются ссылками на скачивание.
|
||||||
- **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`.
|
- **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`.
|
||||||
- **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`.
|
- **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`.
|
||||||
- **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети).
|
- **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети).
|
||||||
@@ -448,6 +552,12 @@ node api.smoketest.js # сквозная проверка API (нужен
|
|||||||
| `GET/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки |
|
| `GET/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки |
|
||||||
| `GET` | `/api/backup` | Скачать бэкап |
|
| `GET` | `/api/backup` | Скачать бэкап |
|
||||||
| `POST` | `/api/restore` | Восстановить из бэкапа |
|
| `POST` | `/api/restore` | Восстановить из бэкапа |
|
||||||
|
| `GET` | `/api/notifications` | Уведомления пользователя (`limit`, `offset`, `unread=1`) |
|
||||||
|
| `GET` | `/api/notifications/stream` | SSE-поток уведомлений (заголовок `X-Auth-Token` или `?token=`) |
|
||||||
|
| `GET` | `/api/notifications/meta` | Каталог типов событий и текущие переключатели (admin) |
|
||||||
|
| `POST` | `/api/notifications/:id/read`, `/api/notifications/read-all` | Отметить прочитанным |
|
||||||
|
| `POST` | `/api/notifications/test` | Тестовое уведомление (admin) |
|
||||||
|
| `DELETE` | `/api/notifications/:id`, `/api/notifications` | Удалить уведомление / очистить все (admin) |
|
||||||
| `GET` | `/api/dashboard`, `/api/stats` | Статистика |
|
| `GET` | `/api/dashboard`, `/api/stats` | Статистика |
|
||||||
|
|
||||||
Авторизация — по сессиям, не по статическому токену:
|
Авторизация — по сессиям, не по статическому токену:
|
||||||
|
|||||||
@@ -0,0 +1,732 @@
|
|||||||
|
# TODO: фото-ИИ с восстановлением лиц (Real-ESRGAN + GFPGAN/CodeFormer), авто-выбор GPU/CPU
|
||||||
|
|
||||||
|
Источник требований: `PLAN_PHOTO_FACE_AI.md`. Этот файл — **исполняемый чек-лист для агента**,
|
||||||
|
который пишет код. План не дублируется: здесь только задачи, якоря в коде, контракты и приёмка.
|
||||||
|
|
||||||
|
Перед стартом агент обязан прочитать `AGENTS.md` (правила проекта) и `PLAN_PHOTO_FACE_AI.md` целиком.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Жёсткие инварианты (нарушение = регресс, работа не принята)
|
||||||
|
|
||||||
|
- [x] **I1. Обратная совместимость `/enhance`.** Запрос только с `image` + `scale=2` (без `model`/`face`)
|
||||||
|
обязан давать тот же JPEG, что и до изменений: `x2plus`, `half=False` на CPU, `IMWRITE_JPEG_QUALITY=92`.
|
||||||
|
Проверяется сравнением с эталоном из Stage 0. Эталон снят (см. «Журнал раздела 0»), прогон
|
||||||
|
`node backups/photo-face-ai-baseline/capture.js <label> && node backups/photo-face-ai-baseline/verify.js <label>`
|
||||||
|
даёт байт-в-байт совпадение; повторная проверка обязательна на приёмке Stage 1.
|
||||||
|
- [x] **I2. Старые задания.** `photo_jobs` со `params IS NULL` и `action='ai'` продолжают обрабатываться
|
||||||
|
воркером как раньше (дефолты подставляются на стороне воркера/сервиса).
|
||||||
|
- [x] **I3. photo-ai не обязателен.** Пустой `PHOTO_AI_URL` → кнопка «🤖 ИИ» скрыта
|
||||||
|
(`public/js/journal.js:718`), `POST /api/entries/:id/photo/enhance-ai` → `503`
|
||||||
|
(`server.js:5115`), приложение полностью работоспособно.
|
||||||
|
- [x] **I4. GPU не обязателен.** Нет `nvidia-ctk` / нет CUDA → контейнер поднимается, `PHOTO_AI_DEVICE=cuda`
|
||||||
|
даёт WARN в лог и молчаливый откат на CPU. Падение `/health` из-за отсутствия GPU недопустимо.
|
||||||
|
Проверено на этом хосте: `nvidia-ctk` **отсутствует**, nvidia-runtime в `docker info` нет, `photo-ai`
|
||||||
|
поднялся и `/health` → `{"ok":true}` (GPU недоступен → сервис не падает). Переменная
|
||||||
|
`PHOTO_AI_DEVICE` появится только в Stage 1 — проверка `cuda` → WARN + CPU откат переносится
|
||||||
|
на приёмку Stage 1, сам GPU-режим на этом хосте не проверяется (стоп-условие: нужен
|
||||||
|
`nvidia-container-toolkit`, ставить без разрешения оператора нельзя).
|
||||||
|
- [x] **I5. Воркер не сжигает попытки на «мягких» ошибках.** `503` / недоступный сервис → задание возвращается
|
||||||
|
в `pending` **без** инкремента `attempts`; лимит мягких повторов (например 60) → одна честная ошибка.
|
||||||
|
- [x] **I6. Никаких новых колонок в БД.** Метаданные обработки (модель, устройство, время, `faces_found`)
|
||||||
|
живут в `photo_jobs.params` (JSONB уже есть) и в `audit_log.target`.
|
||||||
|
- [x] **I7. Код без комментариев** (правило `AGENTS.md`), CommonJS в Node-части, параметризованный SQL,
|
||||||
|
никаких прямых `fs.*` по `uploads/` (только `storage.*`).
|
||||||
|
|
||||||
|
### Журнал раздела 0 (проверено 2026-09-28)
|
||||||
|
|
||||||
|
Что сделано, кроме проверки: в `worker.js` устранены два нарушения инвариантов — прямой
|
||||||
|
`fs.writeFileSync` в `uploads/` вместо `storage.put` (I7) и сжигание `attempts` на `503`/недоступности
|
||||||
|
(I5). В `server.js` флаг `photo_ai_enabled` вынесен из кэша `public-settings` (I3). Новые переменные
|
||||||
|
`PHOTO_AI_SOFT_*` описаны в `.env.example`.
|
||||||
|
|
||||||
|
| Инвариант | Как проверено | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| I1 | `capture.js` → `before/`, повтор `repeat1/`, прогон после правок `after-section0/`; `verify.js` сравнивает sha256 | 5/5 байт-в-байт, `/health` `{"ok":true}` |
|
||||||
|
| I2 | запись `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES (385,'ai','pending',NULL)` | `done`, `attempts=0`, результат отдаётся из S3 (200) |
|
||||||
|
| I3 | `docker compose -f docker-compose.yml -f no-photo-ai.yml up -d app` (`PHOTO_AI_URL: ""`) | `photo_ai_enabled=false`, `enhance-ai` → 503, 8 маршрутов API живы |
|
||||||
|
| I4 | `backups/photo-face-ai-baseline/env.txt` | `nvidia-ctk` NOT_FOUND, `nvidia-container-runtime` NOT_FOUND, runtimes `runc`/`io.containerd.runc.v2`, `photo-ai` Up, `/health` ok |
|
||||||
|
| I5 | `photo-ai` остановлен → задание ждало 70 с → вернулось `pending` при `attempts=0`; после `docker compose start photo-ai` → `done` при `attempts=0`. Лимит проверен прогоном с `PHOTO_AI_SOFT_MAX_RETRIES=2` | мягкий повтор не тратит попытки; после лимита `status=error`, `attempts=0`, текст «мягкие повторы исчерпаны (2)» |
|
||||||
|
| I6 | `git status db/` пуст, `information_schema.columns` для `photo_jobs` — те же 12 колонок; метаданные повторов в `audit_log.target` (`soft_attempt`, `soft_limit`) | без изменений схемы |
|
||||||
|
| I7 | `node --check worker.js server.js api.smoketest.js public/js/audit.js`; `grep` по `worker.js` — ни `require('fs')`, ни `require('path')`, ни `uploadsDir`; в диффе нет строк с комментариями; все SQL — на `$1/$2` | чисто |
|
||||||
|
|
||||||
|
Эталон и скрипты — в `backups/photo-face-ai-baseline/` (вне git): `env.txt`, `inputs/`, `inputs.json`,
|
||||||
|
`before/`, `repeat1/`, `after-section0/`, `capture.js`, `verify.js`, `make-inputs.js`.
|
||||||
|
Тестовые задания (id 90–94), их аудит, уведомления и 3 объекта результата в S3 удалены.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Что уже есть (сверено в коде, не перепроверять)
|
||||||
|
|
||||||
|
| Место | Сейчас | Что меняем |
|
||||||
|
|---|---|---|
|
||||||
|
| `photo-ai/app.py:15–73` | одна модель, `MODEL_PATH`, `MAX_INPUT_PIXELS`, `lock`+`upsampler`, `/health` → `{ok}`, `/enhance(image, scale)` | реестр моделей, `pick_device()`, `ModelPool`, расширенные `/health` и `/models` |
|
||||||
|
| `photo-ai/Dockerfile` | `python:3.10-slim`, `torch --index-url .../whl/cpu`, `realesrgan==0.3.0`, патч `basicsr/data/degradations.py` | `ARG TORCH_VARIANT`, `gfpgan`/`facexlib`, вендоринг CodeFormer |
|
||||||
|
| `docker-compose.yml:194–204` | `photo-ai`: `MODEL_PATH`, `MAX_INPUT_PIXELS`, том `photo-ai-models`, `ports: 127.0.0.1:8081:8080` (D1) | новые env + `healthcheck` |
|
||||||
|
| `docker-compose.yml:208` | том `photo-ai-models` | без изменений |
|
||||||
|
| `server.js:1841` | `PHOTO_AI_URL` | + константы `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_FACE_TIMEOUT_MS` |
|
||||||
|
| `server.js:1237–1256` | `ensurePhotoJobsTable()`: колонка `action VARCHAR(20)`, `params JSONB`, CHECK на action **нет** | миграция схемы не требуется |
|
||||||
|
| `server.js:2254` | `PHOTO_JOB_ACTIONS = new Set(['ai','enhance','restore','rollback'])` | + `'ai_face'`, `'ai_upscale'` |
|
||||||
|
| `server.js:5114–5136` | `POST .../enhance-ai`: без тела → `action='ai'`, `params=NULL` | приём/валидация `{model, face, face_model, strength}` |
|
||||||
|
| `server.js:5782–5837` | `GET /api/photo-jobs/status`: `service = await aiHealthCheck()` вычисляется, но **в ответ не попадает** | вернуть `service` (это баг-дыра, см. D2) |
|
||||||
|
| `server.js:769–826` | `getStackInfo()`: `app/deps/runtime/database/cache/storage` | + блок `photo_ai` |
|
||||||
|
| `server.js:1663–1664` | `keys`/`defaults` для `/api/public-settings` | + `photo_ai_face_mode`, `photo_ai_face_model`, `photo_ai_device_pref` |
|
||||||
|
| `server.js:1710` | валидация `photo_enhance_engine` в `PUT /api/settings` | + валидация трёх новых ключей |
|
||||||
|
| `server.js:6045–6056` | `createPhotoEnhanceWorker({...})` | + `faceTimeoutMs`, `defaultFaceModel` |
|
||||||
|
| `worker.js:11` | `PHOTO_AI_TIMEOUT_MS = 300000` (хардкод) | читать env, добавить face-таймаут |
|
||||||
|
| `worker.js:35–41` | `CONFIG` воркера | + `face_model`, `default_model`, `face_timeout_ms` |
|
||||||
|
| `worker.js:122–141` | `runAiEnhance(srcKey)`: `image` + `scale=2`, ждёт сырой `image/jpeg` | проброс `params`, `Accept: application/json`, разбор JSON, `503` без траты попыток |
|
||||||
|
| `worker.js:176` | `job.action === 'ai' ? runAiEnhance : enhanceWithSharp` | + `'ai_face'`, `'ai_upscale'` |
|
||||||
|
| `worker.js:186–192` | инкремент `attempts` и `photo.job.retry` | не инкрементить при `503`/недоступности |
|
||||||
|
| `public/js/journal.js:606–615` | `loadEnhanceEngine()` читает `/api/public-settings` | + чтение дефолта face-режима |
|
||||||
|
| `public/js/journal.js:617–671` | `runPhotoAi()`: `confirm()` + POST без тела | + селект модели, тело `{model, face, face_model}` |
|
||||||
|
| `public/js/journal.js:718` | показ кнопки по `photo_ai_enabled` | без изменений (I3) |
|
||||||
|
| `public/js/worker.js:30–35` | `PHOTO_ACTION_LABELS` | + `ai_face`, `ai_upscale` |
|
||||||
|
| `public/js/worker.js:300–308` | `openPhotoJob(r)` — модалка «Было/Стало» | + `model`/`device`/`faces_found`/`elapsed_ms` |
|
||||||
|
| `public/js/settings.js:1` | `DIRTY_FIELDS` | + новые поля |
|
||||||
|
| `public/js/settings.js:217–218, 739` | загрузка/сохранение `photo_enhance_engine` | + face-режим, face-модель, желаемое устройство |
|
||||||
|
| `public/js/settings.js:372–477` | `renderStackInfo()` — 5 карточек | + карточка «Фото-ИИ» |
|
||||||
|
| `public/settings.html:221–260` | карточка `#sec-photo` (камера) | + блок «Фото-ИИ» (или новая карточка) |
|
||||||
|
| `public/js/audit.js:34–48` | словарь `photo.*` | + новые коды аудита |
|
||||||
|
| `db/init.sql:242`, `db/migration.sql:234` | `INSERT ... 'photo_worker_enabled'` | + дефолты новых настроек |
|
||||||
|
| `.env.example:39–41` | `PHOTO_AI_URL`, `PHOTO_AI_MAX_PIXELS` | + остальные переменные |
|
||||||
|
| `photo-ai` в compose | **порт не проброшен**; `text-corrector` занимает `8080:8080` | см. D1 — ручные `curl` из плана не сработают |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Расхождения плана с кодом — решить ДО правок
|
||||||
|
|
||||||
|
Все шесть расхождений закрыты 2026-09-28 (решения ниже, правки кода — в своих этапах).
|
||||||
|
|
||||||
|
- [x] **D1. Порт для ручных проверок.** `photo-ai` не имеет `ports`/`expose`, а хостовый `8080` занят
|
||||||
|
`text-corrector`. Команды вида `curl http://localhost:8080/enhance` из §10 плана попадут
|
||||||
|
в `text-corrector`. Решение выбрано одно: пробросить `ports: ["127.0.0.1:8081:8080"]` в сервис
|
||||||
|
`photo-ai` — только loopback, наружу (`0.0.0.0`) ничего не публикуется, хостовый `8080`
|
||||||
|
(`text-corrector`) не затрагивается. **Правка применена 2026-09-28:** порт добавлен в
|
||||||
|
`docker-compose.yml`, способ отражён в `README.md` (раздел «ИИ-улучшение фото») и `.env.example`.
|
||||||
|
Проверено: `docker compose up -d photo-ai` → `curl http://127.0.0.1:8081/health` → `{"ok":true}`,
|
||||||
|
`ss -tulpn` → `LISTEN 127.0.0.1:8081` (не `0.0.0.0`).
|
||||||
|
|
||||||
|
- [x] **D2. `service` в статусе фото-воркера.** `server.js:5825` вызывает `aiHealthCheck()` (это health
|
||||||
|
**текстового** ИИ), результат не возвращается. Решение: отдельная функция, общий `aiHealthCheck()`
|
||||||
|
не переиспользуем и из `/api/photo-jobs/status` убираем.
|
||||||
|
- `photoAiHealth()` рядом с `aiHealthCheck()`: `GET ${PHOTO_AI_URL}/health`, таймаут 5 с, без throw;
|
||||||
|
пустой `PHOTO_AI_URL` → `{ configured: false, reachable: false, latency_ms: 0, error: 'PHOTO_AI_URL не настроен' }`,
|
||||||
|
недоступность/таймаут → `{ configured: true, reachable: false, latency_ms, error }`.
|
||||||
|
- `service` в ответе = `{ configured, reachable, latency_ms, error }` + passthrough полей photo-ai
|
||||||
|
(`ok, ready, device, device_name, half, tile, driver, cuda, vram_total_mb, vram_free_mb, models,
|
||||||
|
face_models, loaded, loading, max_pixels`). Первые четыре — тот же контракт, который уже читает
|
||||||
|
фронт текстового ИИ (`public/js/worker.js:89–116`: `reachable`, `latency_ms`, `error`),
|
||||||
|
поэтому карточки переиспользуются без правок.
|
||||||
|
- Вызов уходит в тот же `Promise.all`, что и запросы к БД, — иначе статус получает лишние 5 с;
|
||||||
|
HTTP 200 в любом случае, недоступный сервис — не ошибка API.
|
||||||
|
- Прямой прокси для оператора — `GET /api/photo-ai/health` (`requireAdmin`), отдельным пунктом Stage 4.
|
||||||
|
- **Правка применена 2026-09-28:** `photoAiHealth()` в `server.js` рядом с `aiHealthCheck()`,
|
||||||
|
вызов ушёл в `Promise.all` запроса `/api/photo-jobs/status`, `service` возвращается.
|
||||||
|
Проверено на живом стеке: сервис поднят → `{"ok":true,"configured":true,"reachable":true,"latency_ms":3,"error":null}`;
|
||||||
|
`docker compose stop photo-ai` → HTTP 200 и `{"configured":true,"reachable":false,"latency_ms":3661,"error":"fetch failed"}`
|
||||||
|
(без исключения); после `start` → снова `reachable: true`. Контракт зафиксирован в
|
||||||
|
`api.smoketest.js` (два новых assert'а).
|
||||||
|
|
||||||
|
- [x] **D3. Где живёт белый список действий.** План говорит про два места (`ensurePhotoJobsTable()` и restore).
|
||||||
|
По факту `PHOTO_JOB_ACTIONS` используется **только** в нормализации restore-данных (`server.js:2254`,
|
||||||
|
единственное применение — `server.js:2259`; `grep` по репозиторию больше нигде), а в
|
||||||
|
`ensurePhotoJobsTable()` (`server.js:1237–1254`) CHECK-ограничения на `action` нет:
|
||||||
|
`action VARCHAR(20) NOT NULL DEFAULT 'ai'` — новые значения (`ai_face`, `ai_upscale`) помещаются.
|
||||||
|
Решение: обновить одно место.
|
||||||
|
- Набор выносится на уровень модуля (рядом с `PHOTO_AI_URL`, `server.js:1841`), а не внутрь
|
||||||
|
функции restore; используется в restore-нормализации **и** в валидации
|
||||||
|
`POST /api/entries/:id/photo/enhance-ai` (Stage 4) — забыть маршрут нельзя.
|
||||||
|
- CHECK в БД **не добавляем** (I6: никаких изменений схемы, миграция не нужна). Сверка списка
|
||||||
|
с фактическими `action`, которые пишет код, — `grep` на приёмке этапа.
|
||||||
|
- `ai_upscale` в список попадает сразу, хотя маршрута, его создающего, пока нет: список —
|
||||||
|
это допустимые значения для restore, а не реестр маршрутов.
|
||||||
|
|
||||||
|
- [x] **D4. Доступность пакетов.** Проверено 2026-09-28 в работающем контейнере `photo-ai`
|
||||||
|
(`pip download gfpgan==1.3.8 facexlib==0.3.0 --no-deps` — оба колеса с PyPI, 52 и 59 КБ;
|
||||||
|
`pip list` в образе: `gfpgan 1.3.8`, `facexlib 0.3.0`, `basicsr 1.4.2`, `realesrgan 0.3.0`,
|
||||||
|
`filterpy 1.4.5`, `numba 0.67.0`, `lmdb 2.3.0`, `scipy 1.15.3`; `import gfpgan, facexlib` — ок).
|
||||||
|
Решение: **вендорить не нужно**, пакеты есть на PyPI и уже стоят в образе — `realesrgan==0.3.0`
|
||||||
|
тянет `gfpgan>=1.3.5` и `facexlib>=0.2.5` транзитивно. CodeFormer официальным pip-пакетом
|
||||||
|
не распространяется (см. D5).
|
||||||
|
- Stage 2 фиксирует версии явно (`gfpgan==1.3.8 facexlib==0.3.0`) и убирает dev-зависимости
|
||||||
|
gfpgan из рантайма (`tb-nightly`, `yapf`): ставить gfpgan с `--no-deps` и перечислить
|
||||||
|
реальные зависимости явно.
|
||||||
|
- **Найденное расхождение (Stage 2):** пин `numpy<2` в `photo-ai/Dockerfile` не действует — в образе
|
||||||
|
`numpy 2.2.6`, потому что пин живёт в отдельном вызове `pip install`, который следующие установки
|
||||||
|
не учитывают; там же одновременно стоят `opencv-python 5.0.0.93` (через gfpgan) и
|
||||||
|
`opencv-python-headless 5.0.0.93`. До Stage 2 numpy не трогаем (Stage 1 меряет I1 на текущем
|
||||||
|
2.2.6); в Stage 2 пин либо переносится в один общий вызов `pip install`, либо фиксируется
|
||||||
|
фактическая версия — с обязательной перепроверкой эталона I1 после любого изменения numpy.
|
||||||
|
- **Закрыто в Stage 2:** требования `numpy<2` и `opencv-python-headless 5.0.0.93` несовместимы
|
||||||
|
(opencv 5 требует numpy ≥ 2), поэтому зафиксирована фактическая версия `numpy==2.2.6` в том же
|
||||||
|
вызове `pip install`, что и остальные зависимости, а `opencv-python` (не-headless) больше не
|
||||||
|
устанавливается вовсе. Эталон I1 перепроверен — 5/5 байт-в-байт (см. «Журнал раздела 2»).
|
||||||
|
- Веса (проверено HEAD): `x2plus` v0.2.1 — 200; `general-x4v3` и `animevideov3` v0.2.5.0 — 200;
|
||||||
|
`codeformer.pth` v0.1.0 — 200. GFPGAN: берём `GFPGANv1.4.pth` из релиза `v1.3.0` (200, `arch='clean'`,
|
||||||
|
`channel_multiplier=2` — как у официального `inference_gfpgan.py -v 1.4`); `GFPGANCleanv1-NoCE-C2.pth`
|
||||||
|
в релизах `v1.3.8`/`v1.3.4`/`v1.3.0` отсутствует (404), в `v0.2.0` есть (200) — как запасной вариант.
|
||||||
|
- **Учтётся в Stage 1:** `GFPGANer` жёстко передаёт facexlib `model_rootpath='gfpgan/weights'`
|
||||||
|
(относительный путь → каталог образа, не том `/models`), поэтому веса детектора/парсера facexlib
|
||||||
|
сейчас скачиваются мимо тома и теряются при пересборке. Свой `FaceRestoreHelper`/каталог
|
||||||
|
`/models/weights` — обязательное требование этапа.
|
||||||
|
|
||||||
|
- [x] **D5. CodeFormer — опционален по умолчанию.** Проверено 2026-09-28: на PyPI есть только
|
||||||
|
сторонняя обёртка `codeformer 0.0.11` (`github.com/rohitkhatri/codeformer`, тянет `lpips`) —
|
||||||
|
это не официальный `sczhou/CodeFormer`, использовать его не будем. Официальные веса доступны.
|
||||||
|
Решение: официальный модуль вендорится в `photo-ai/vendor/codeformer/` **только если** face-режим
|
||||||
|
CodeFormer реально понадобится; в рамках текущего плана не вендорим, дефолт
|
||||||
|
`PHOTO_AI_FACE_MODEL=gfpgan` (Stage 1–3), чтобы дефолтный путь работал без вендоринга.
|
||||||
|
- `FACE_REGISTRY` всегда содержит обе записи, но запись `codeformer` активна только если
|
||||||
|
`import codeformer` (с `photo-ai/vendor` в `sys.path`) успешен.
|
||||||
|
- Нет модуля → запись не попадает в `face_models` в `/health` и в список `/models`, UI её не показывает,
|
||||||
|
`POST /enhance` с `face_model=codeformer` → `400` с текстом «CodeFormer не установлен в образ,
|
||||||
|
доступен gfpgan». Сборка и CPU-режим не падают.
|
||||||
|
- `strength` валиден только для CodeFormer: при `face_model=gfpgan` и `strength`, отличном от 0.7,
|
||||||
|
→ `400` с пояснением, иначе параметр молча игнорировался бы.
|
||||||
|
- Веса CodeFormer качаются тем же загрузчиком в `/models/weights/codeformer.pth`.
|
||||||
|
|
||||||
|
- [x] **D6. Поведение без `photo-ai` в compose.** Сейчас `PHOTO_AI_URL` по умолчанию
|
||||||
|
`http://photo-ai:8080` (`docker-compose.yml:79`) — то есть «ИИ» включён по умолчанию.
|
||||||
|
Решение: дефолт **не меняем** — фото-ИИ включено из коробки и работает на CPU; выключается
|
||||||
|
только явно (`PHOTO_AI_URL=` в `.env` → кнопка «🤖 ИИ» скрыта, `enhance-ai` → 503, I3).
|
||||||
|
Заодно исправлен найденный рассинхрон: `.env.example` содержал `PHOTO_AI_URL=` (пусто) с комментарием
|
||||||
|
«пусто = контейнер photo-ai», то есть инструкция «скопируй `.env.example`» молча выключала ИИ-фото.
|
||||||
|
В этом же изменении `.env.example` приведён к дефолту compose с явным описанием обоих состояний;
|
||||||
|
блок про photo-ai в `README` (включая GPU-запуск) появится в Stage 6 с той же формулировкой.
|
||||||
|
Текущий `.env` переменной не содержит → на этом хосте действует дефолт compose (включено).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stage 0. Подготовка и эталон «до» (обязательно до любых правок кода)
|
||||||
|
|
||||||
|
- [x] Зафиксировать окружение: `docker --version`, `docker compose version`, `nvidia-smi`,
|
||||||
|
наличие/отсутствие `nvidia-ctk`, `docker info | grep -i runtime`.
|
||||||
|
- [x] Если `nvidia-ctk` нет — зафиксировать это как «GPU-режим не проверяем на этом хосте»,
|
||||||
|
**не** пытаться ставить системные пакеты без явного разрешения оператора.
|
||||||
|
- [x] Сохранить текущее состояние `.env` (`PHOTO_AI_URL` пусто или задан).
|
||||||
|
- [x] Поднять текущий стек как есть: `docker compose up -d --build`.
|
||||||
|
- [x] Снять эталон «до» на 3–5 фото (портрет, групповое, без лиц, зашумлённое 640×480):
|
||||||
|
результат `/enhance` с `scale=2` без других параметров + `/health`. Сохранить файлы и
|
||||||
|
размеры в `backups/photo-face-ai-baseline/` (вне git).
|
||||||
|
- [x] **Приёмка:** эталон сохранён, `curl /health` отвечает, текущий сценарий «🤖 ИИ» даёт `done`.
|
||||||
|
|
||||||
|
Stage 0 закрыт 2026-09-28, журнал проверок — «Журнал раздела 0» выше. Повторная сверка при закрытии:
|
||||||
|
`node backups/photo-face-ai-baseline/verify.js after-section0` → 5/5 `MATCH` (I1 байт-в-байт),
|
||||||
|
`photo-ai /health` → `{"ok":true}` из контейнера `app`, сценарий «🤖 ИИ» → `done` (I2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Stage 1. `photo-ai/app.py`: реестр моделей + авто-выбор устройства
|
||||||
|
|
||||||
|
- [x] `pick_device() -> (device, half, device_name)` по §4.1 плана: env → cuda → mps → cpu;
|
||||||
|
`half=True` только на CUDA; явный `cuda` без CUDA = WARN + cpu; явный `cpu` = всегда cpu.
|
||||||
|
- [x] `MODEL_REGISTRY`: `x2plus` (`RRDBNet(scale=2)`), `general-x4v3` (`SRVGGNetCompact(upscale=4)` + `wdn`),
|
||||||
|
`animevideo-v3` (`SRVGGNetCompact(upscale=4)`).
|
||||||
|
- [x] `FACE_REGISTRY`: `gfpgan` (`GFPGANer(arch='clean', channel_multiplier=2, upscale=2, bg_upsampler=…)`),
|
||||||
|
`codeformer` (за `D5`).
|
||||||
|
- [x] Загрузка весов: список URL из §3.4 плана, каталог `${PHOTO_AI_MODELS_DIR:-/models}/weights/`,
|
||||||
|
скачивание в `.tmp` → `os.replace`, проверка минимального размера, кэш в томе.
|
||||||
|
`MODEL_PATH` читается как алиас для `x2plus` (существующий `.env`/том не ломается).
|
||||||
|
- [x] `class ModelPool`: `threading.Lock`, ленивая загрузка по требованию, кеш, LRU с лимитом 2,
|
||||||
|
`PHOTO_AI_LOAD_ALL=1` — предзагрузка, прогрев на синтетическом шуме 64×64 после загрузки.
|
||||||
|
- [x] OOM-деградация: `RuntimeError` с CUDA OOM → `tile` пополам (256→128→64), один ретрай;
|
||||||
|
повтор → инвалидация модели, переход на CPU, ещё одна попытка; финал — `500` с понятным текстом.
|
||||||
|
- [x] `GET /health` — контракт §4.3 плана (поле `ok` сохраняется, добавляются `ready`, `device`,
|
||||||
|
`device_name`, `half`, `tile`, `driver`, `cuda`, `vram_total_mb`, `vram_free_mb`, `models`,
|
||||||
|
`face_models`, `loaded`, `loading`, `max_pixels`).
|
||||||
|
- [x] `GET /models` — список моделей, face-моделей, устройство, дефолты.
|
||||||
|
- [x] `POST /enhance`: поля `image`, `scale`, `model` (дефолт `x2plus`), `face` (`off|face|all`, дефолт `off`),
|
||||||
|
`face_model` (`gfpgan|codeformer`), `strength` (0..1, только CodeFormer, дефолт 0.7),
|
||||||
|
`jpeg_quality` (70..100, дефолт 92). Неизвестная модель → `400` со списком допустимых.
|
||||||
|
Модель не готова → `503` + `Retry-After: 5`.
|
||||||
|
- [x] Два формата ответа: сырой `image/jpeg` по умолчанию (совместимость) и JSON при
|
||||||
|
`Accept: application/json`: `{ok, image_base64, model, face, face_model, faces_found, device,
|
||||||
|
elapsed_ms, warnings}`. При `face != off` — `enhance(..., has_aligned=False, only_center_face=False,
|
||||||
|
paste_back=True)`; лица не найдены — не ошибка, `faces_found: 0` + чистый `bg_upsampler`.
|
||||||
|
- [x] Расширение выходного файла — по имени файла, не по `content_type` (воркер шлёт `image/jpeg` для всего).
|
||||||
|
- [x] **Проверка I1:** эталон из Stage 0 воспроизводится (сравнить размер/содержимое, `node --check`-эквивалент
|
||||||
|
для Python — `python -c "import ast;ast.parse(open('photo-ai/app.py').read())"`).
|
||||||
|
- [x] **Приёмка:** `/health` отдаёт `device`/`device_name`/`half`; `PHOTO_AI_DEVICE=cpu` при рабочей CUDA →
|
||||||
|
`cpu`; `PHOTO_AI_DEVICE=cuda` без CUDA → `cpu` + WARN, сервис поднялся; `PHOTO_AI_DEVICE=auto` без
|
||||||
|
CUDA → `cpu`.
|
||||||
|
|
||||||
|
### Журнал раздела 1 (проверено 2026-09-29)
|
||||||
|
|
||||||
|
Изменён только `photo-ai/app.py` (схема БД, воркер, `server.js`, compose, Dockerfile, фронтенд и `.env.example`
|
||||||
|
не тронуты — они в Stage 2…6).
|
||||||
|
|
||||||
|
| Проверка | Как | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| I1 | `capture.js after-stage1` + `verify.js after-stage1` | 5/5 `MATCH` байт-в-байт; повторно после двух правок `is_oom`/`BASE_TILE` — снова 5/5 |
|
||||||
|
| I2 | `INSERT INTO photo_jobs (entry_id, action, params, status) VALUES (390,'ai',NULL,'pending')` | `done`, `attempts=0`, `after_path` в S3; вход 939×875 PNG → 1878×1750 JPEG (ровно x2) |
|
||||||
|
| I4 | override-файлы с `PHOTO_AI_DEVICE=cuda\|cpu\|auto\|bogus` | `cuda` → WARN «CUDA недоступна — работаю на CPU» + `device=cpu`; `cpu`/`auto` → `cpu`; `bogus` → WARN об неизвестном значении и `auto`; сервис поднялся во всех случаях |
|
||||||
|
| I5 | 503 во время стартовой предзагрузки | `503` + `Retry-After: 5`; `worker.js:19` `isSoftStatus` относит `status >= 500` к мягким, попытка не тратится |
|
||||||
|
| I7 | `ast.parse`, `grep` на комментарии, неиспользуемые импорты | чисто, комментариев 0 |
|
||||||
|
| Реестр | `general-x4v3` (360×548 → 1440×2192), `animevideo-v3` | 200, веса скачаны в том, повторный запрос из кэша |
|
||||||
|
| LRU | последовательно `general-x4v3` → `animevideo-v3` → `face=all` | `loaded` держится ровно 2 записи, вытесняется самая старая (`x2plus` → `general-x4v3` → `animevideo-v3` → `gfpgan`) |
|
||||||
|
| Лица | `face=face` и `face=all` на реальных фото (800×450 и 1400×788) | `faces_found: 10`, 47 с / 42 с на CPU, `warnings: []` |
|
||||||
|
| Лица, их нет | `nofaces.jpg` 300×200 | `ok: true`, `faces_found: 0`, warning «лица не найдены, фон обработан апскейлом», 1.8 с — не ошибка |
|
||||||
|
| `jpeg_quality` | 70 / 92 / 100 и 30 / 101 / `abc` | 165935 / 327022 / 929904 байт, повтор 70 → те же байты; вне диапазона `400` с текстом «должен быть 70..100», нечисловое — `422` от pydantic |
|
||||||
|
| Валидация | неизвестные `model`/`face`/`face_model`, `strength` с gfpgan и вне 0..1, битое изображение | `400` с перечнем допустимых значений / `400 bad image` |
|
||||||
|
| OOM-лестница | подмена `process_image` в контейнере: OOM на 0/1/2/всех попытках | тайлы `[256]`, `[256,128]`, `[256,128,64]`, `[256,128,64]`; при полном провале `500` «не хватило памяти даже при tile=64: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS» |
|
||||||
|
| Деградация | повторный вызов `degrade_to_cpu` | выполняется один раз (идемпотентно), `half=False`, предупреждение в ответе |
|
||||||
|
|
||||||
|
Решения и находки, которые нужно знать дальше:
|
||||||
|
|
||||||
|
- **GPU на хосте есть** — `nvidia-smi` работает, драйвер `615.71.09`, CUDA UMD 13.4. Не хватает только
|
||||||
|
`nvidia-container-toolkit`; его установка — решение оператора (в условиях задачи это стоп-условие),
|
||||||
|
поэтому ветка CUDA осталась непроверенной. Всё, что связано с `cuda`/`half`/`vram_*`, в Stage 1
|
||||||
|
покрыто только кодом и unit-проверками, а не прогоном.
|
||||||
|
- **`animevideo-v3`** — имя реестра взято по формулировке этого чек-листа (в плане §3.4 встречается
|
||||||
|
`animevideov3`, это имя файла весов). Ключ используется в API, при несовпадении с планом поправить
|
||||||
|
и то, и другое одним изменением.
|
||||||
|
- **`image_ext`** добавлен в JSON-ответ сверх полей, перечисленных в чек-листе: без него клиент
|
||||||
|
не может угадать формат (`.jpeg` нормализуется в `.jpg` ради байт-в-байтности, `.png`/`.webp`
|
||||||
|
не меняются).
|
||||||
|
- **D4 (веса facexlib в томе)**: `face_helper()` пишет в `${PHOTO_AI_MODELS_DIR}/weights` через
|
||||||
|
`model_rootpath`, а встроенный помощник `GFPGANer` ищет `gfpgan/weights` относительно cwd.
|
||||||
|
`link_default_facexlib_dir()` при первом же построении face-модели заменяет этот каталог
|
||||||
|
символической ссылкой на том — `/app/gfpgan/weights -> /models/weights`. Второй детектор не
|
||||||
|
создаётся, 195 МБ дубля нет (проверено `readlink`).
|
||||||
|
- **Ветка CodeFormer написана, но не проверена** — модуль не вендорен (Stage 2). Проверен только
|
||||||
|
путь отказа: `face_model=codeformer` → `400` «модель лиц codeformer не установлена в образ,
|
||||||
|
доступен gfpgan», и `strength` с gfpgan → `400`. Сам `build_face_codeformer` нужно прогнать после
|
||||||
|
того, как пакет появится в образе.
|
||||||
|
- **`tile` не залипает.** После OOM `state['tile']` восстанавливается на `PHOTO_AI_TILE` в `finally`
|
||||||
|
(`BASE_TILE`), иначе одна тяжёлая картинка навсегда замедляла бы сервис в 16 раз. `degrade_to_cpu`
|
||||||
|
больше не сбрасывает тайл на дефолт — операторское значение сохраняется.
|
||||||
|
- **`is_oom` расширен** на сообщения CPU-аллокатора PyTorch (`not enough memory`, `alloc_cpu`,
|
||||||
|
`can't allocate memory`): без этого исчерпание RAM на CPU (единственный тестируемый режим)
|
||||||
|
возвращало `500` с текстом «ошибка модели: …» вместо лестницы тайлов и подсказки про
|
||||||
|
`PHOTO_AI_TILE`/`PHOTO_AI_MAX_PIXELS`.
|
||||||
|
- **`portrait.jpg` — плохой тестовый вход**: на нём детектор facexlib не находит лица (порог
|
||||||
|
`get_face_landmarks_5` — 0.97, на реальных фото скор 0.999+). Ранние «0 лиц» в проверках были
|
||||||
|
ошибкой тест-скрипта (`len(h.cropped_faces)` заполняется только `align_warp_face()`), а не
|
||||||
|
поломкой детектора. Годится любой портрет из `uploads/` (4032×2268 и ещё 7 файлов).
|
||||||
|
- **`.env.example` не правился**: новые переменные ещё не подставляются в compose (Stage 2),
|
||||||
|
документировать их сейчас — задокументировать неработающее.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Stage 2. `photo-ai/Dockerfile` + compose
|
||||||
|
|
||||||
|
- [x] `ARG TORCH_VARIANT=cpu` и `ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}`;
|
||||||
|
один образ, `cu124` — вариант сборки.
|
||||||
|
- [x] Сохранить патч `basicsr/data/degradations.py` (`functional_tensor` → `functional`) — без него basicsr
|
||||||
|
падает на torch ≥ 2.0. Не «упрощать» Dockerfile без проверки.
|
||||||
|
- [x] Сохранить `libgl1 libglib2.0-0` (нужны facexlib), `numpy<2`, `opencv-python-headless`.
|
||||||
|
- [x] Добавить `gfpgan`/`facexlib` (или вендоринг по `D4`), вендоренный CodeFormer копировать в образ
|
||||||
|
при наличии (`D5`).
|
||||||
|
- [x] `ENV PHOTO_AI_MODELS_DIR=/models`; `MODEL_PATH` остаётся валидным алиасом.
|
||||||
|
- [x] `docker-compose.yml`, сервис `photo-ai`: env `PHOTO_AI_DEVICE`, `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_TILE`,
|
||||||
|
`PHOTO_AI_MAX_PIXELS`, `PHOTO_AI_LOAD_ALL`, `PHOTO_AI_JPEG_QUALITY`; `healthcheck` с
|
||||||
|
`start_period: 300s`; порт по `D1` уже проброшен на loopback — сохранить.
|
||||||
|
- [x] Новый `docker-compose.gpu.yml` (по образцу `docker-compose.minio.yml`): `build.args.TORCH_VARIANT=cu124`,
|
||||||
|
`PHOTO_AI_DEVICE=cuda`, `deploy.resources.reservations.devices` (`driver: nvidia`, `count: 1`).
|
||||||
|
Без него стек поднимается на любой машине.
|
||||||
|
- [x] Сервису `app` добавить env `PHOTO_AI_FACE_MODEL` и `PHOTO_AI_FACE_TIMEOUT_MS`.
|
||||||
|
- [x] **Приёмка:** CPU-сборка стартует; `/api/photo-jobs/status` показывает `service.device`;
|
||||||
|
при пустом `PHOTO_AI_URL` приложение работает полностью (I3); `docker compose -f docker-compose.yml
|
||||||
|
-f docker-compose.gpu.yml config` валиден (сам GPU-пуск — только если toolkit есть).
|
||||||
|
|
||||||
|
### Журнал раздела 2 (проверено 2026-09-29)
|
||||||
|
|
||||||
|
Изменены `photo-ai/Dockerfile`, `docker-compose.yml`, добавлены `docker-compose.gpu.yml` и
|
||||||
|
`photo-ai/vendor/.gitkeep`, а также `.env.example` и таблица переменных в `README.md` — Stage 1 отложил
|
||||||
|
их именно на Stage 2, потому что раньше compose эти переменные не подставлял. `app.py`, `worker.js`,
|
||||||
|
`server.js`, схема БД и фронтенд не тронуты — они в Stage 3…6.
|
||||||
|
|
||||||
|
| Проверка | Как | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| CPU-сборка | `docker compose build photo-ai` (слои pip с нуля) | `EXIT=0`, образ `whatido-photo-ai:latest` **2.63 ГБ** (план оценивал ~2.5 ГБ) |
|
||||||
|
| Состав пакетов | `docker exec photo-ai pip list` | `gfpgan 1.3.8`, `facexlib 0.3.0`, `basicsr 1.4.2`, `realesrgan 0.3.0`, `numpy 2.2.6`, `torch 2.14.0+cpu`, `matplotlib 3.10.9`; `opencv-python-headless 5.0.0.93` — **единственный** cv2; `tb-nightly` и `yapf` отсутствуют |
|
||||||
|
| Импорты | `python -c "import basicsr, gfpgan, facexlib, realesrgan, cv2"` | ок; cv2 `GUI: NONE` (до этого активной была не-headless сборка с `GUI: QT5`) |
|
||||||
|
| Патч basicsr | лог сборки | `basicsr patch applied to /usr/local/lib/python3.10/site-packages/basicsr/data/degradations.py` |
|
||||||
|
| **I1** | `capture.js after-stage2` + `verify.js after-stage2` | **5/5 `MATCH` байт-в-байт** (`795a519b9a9c70f6`, `fe2496f7ef798456`, `fce1949260590855`, `1b52a49d690ca8d7`, `e0e6e480f4799bc0`) — смена cv2 на headless и явный пин numpy результат не изменили |
|
||||||
|
| `service.device` | `GET /api/photo-jobs/status` (admin) | 200; `service` = `ok/ready/device=cpu/device_name/half/tile/driver/cuda/vram_total_mb/vram_free_mb/models/face_models/loaded/loading/max_pixels` + `configured/reachable/latency_ms/error` |
|
||||||
|
| healthcheck | `docker inspect photo-ai` | `healthy` после `start_period: 300s`; та же команда вручную в контейнере → exit 0 |
|
||||||
|
| env контейнера | `docker inspect photo-ai` | `PHOTO_AI_DEVICE=auto`, `PHOTO_AI_TILE=256`, `PHOTO_AI_FACE_MODEL=gfpgan`, `PHOTO_AI_LOAD_ALL=0`, `PHOTO_AI_JPEG_QUALITY=92`, `PHOTO_AI_MAX_PIXELS=4000000`, `PHOTO_AI_MODELS_DIR=/models`, `MODEL_PATH=/models/RealESRGAN_x2plus.pth` |
|
||||||
|
| env `app` | `docker compose exec app node -e ...` | `PHOTO_AI_URL=http://photo-ai:8080`, `PHOTO_AI_FACE_MODEL=gfpgan`, `PHOTO_AI_FACE_TIMEOUT_MS=600000` |
|
||||||
|
| I3 | override с `PHOTO_AI_URL: ""` (файл в `/tmp`, вне репозитория) | `photo_ai_enabled=false`, `POST /api/entries/1/photo/enhance-ai` → `503` «ИИ-обработка фото не настроена», 8 маршрутов API → 200; возврат к базовой конфигурации → `photo_ai_enabled=true` |
|
||||||
|
| I4 (ветка cuda) | `PHOTO_AI_DEVICE=cuda` на новом образе | WARN «PHOTO_AI_DEVICE=cuda, но CUDA недоступна — работаю на CPU», `/health` 200 и `device=cpu` |
|
||||||
|
| Compose | `docker compose config -q` — база, `+docker-compose.minio.yml`, `+docker-compose.gpu.yml` | три конфигурации валидны; GPU-оверрайд даёт `TORCH_VARIANT=cu124`, `PHOTO_AI_DEVICE=cuda` и `deploy.resources.reservations.devices[driver=nvidia, count=1, capabilities=[gpu]]` |
|
||||||
|
| GPU-пуск | `up -d photo-ai` с оверрайдом, когда `nvidia-ctk` есть на хосте | **сделан 2026-09-29**, отдельный журнал «GPU-прогон» в разделе 6: `device=cuda:0`, `half=true`, `vram_total_mb=3822`. Изначально был стоп-условием (toolkit отсутствовал), условие снято |
|
||||||
|
|
||||||
|
Решения и находки, которые нужно знать дальше:
|
||||||
|
|
||||||
|
- **`numpy<2` был невыполним.** `opencv-python-headless 5.0.0.93` требует numpy ≥ 2, поэтому пин заменён
|
||||||
|
на фактическую версию `numpy==2.2.6` и перенесён в тот же вызов `pip install`, что и остальные
|
||||||
|
зависимости (раньше пин стоял отдельным вызовом, и следующие установки его игнорировали). I1 после
|
||||||
|
изменения numpy перепроверен — байт-в-байт.
|
||||||
|
- **`--no-deps` для `basicsr`/`realesrgan`/`gfpgan`/`facexlib`** убирает dev-зависимости gfpgan
|
||||||
|
(`tb-nightly`, `yapf`). Проверено, что в рантайме они не нужны: `tensorboard` в basicsr импортируется
|
||||||
|
только лениво внутри `init_tb_logger`/`read_data_from_tensorboard`, `yapf` не импортируется вовсе,
|
||||||
|
а `matplotlib` приходит как зависимость `filterpy` (требование facexlib) и в образе остался.
|
||||||
|
- **`opencv-python` (не-headless) больше не ставится.** Раньше он приходил транзитивно через gfpgan и
|
||||||
|
перезаписывал headless-сборку (активным был cv2 с `GUI: QT5`). Теперь cv2 один, headless, и JPEG-байты
|
||||||
|
совпали с эталоном — GUI-обвязка на результат не влияла.
|
||||||
|
- **`libgl1 libglib2.0-0` оставлены** по требованию плана (facexlib), хотя при headless cv2 они нужны
|
||||||
|
меньше: экономия здесь не стоит риска незамеченной регрессии.
|
||||||
|
- **`photo-ai/vendor/.gitkeep`** — каталог вендоринга существует в репозитории, поэтому
|
||||||
|
`COPY vendor/ ./vendor/` не падает на сборке без CodeFormer, а положенный туда модуль подхватывается
|
||||||
|
`sys.path` в `app.py` без правок Dockerfile (`D5`).
|
||||||
|
- **env `PHOTO_AI_FACE_MODEL`/`PHOTO_AI_FACE_TIMEOUT_MS` у `app`** добавлены по чек-листу Stage 2;
|
||||||
|
`PHOTO_AI_FACE_MODEL` уже используется `app.py` как дефолт face-модели, воркер начнёт читать оба
|
||||||
|
в Stage 4 (сейчас `ai_timeout_ms` в `worker.config` всё ещё 300000 — это ожидаемо).
|
||||||
|
- **`TORCH_VARIANT` в `docker-compose.gpu.yml` захардкожен**, а не берётся из `.env`: иначе
|
||||||
|
`TORCH_VARIANT=cpu` в `.env` молча собирал бы GPU-конфигурацию без CUDA. Значение изменено
|
||||||
|
`cu124 → cu126` при GPU-прогоне — причина в журнале раздела 3.
|
||||||
|
- **`MAX_INPUT_PIXELS` в compose заменён на канонический `PHOTO_AI_MAX_PIXELS`**; `app.py` по-прежнему
|
||||||
|
читает старое имя как алиас, так что существующий `.env` не ломается. Порт `8081` на loopback (`D1`)
|
||||||
|
сохранён.
|
||||||
|
- **`.env.example`/`README.md`** описывают ровно те переменные, которые подставляет compose; блок про
|
||||||
|
GPU-запуск и установку NVIDIA Container Toolkit добавлен в Stage 3 вместе с проверкой GPU-оверрайда
|
||||||
|
(решение `D6`), остальные документы — за Stage 6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Stage 3. Face-режим (GFPGAN) в `app.py`
|
||||||
|
|
||||||
|
- [x] `face=off` — только ESRGAN (текущее поведение). — **сделано в Stage 1** (журнал раздела 1)
|
||||||
|
- [x] `face=face` — ESRGAN-фон + GFPGAN `paste_back`. — **сделано в Stage 1**
|
||||||
|
- [x] `face=all` — ESRGAN(+`wdn` для `general-x4v3`) + мягкий денойз фона + лица. — **сделано в Stage 1**
|
||||||
|
- [x] `strength` → `fidelity_weight` CodeFormer, `-w`; вне диапазона → `400`. — **сделано в Stage 1**
|
||||||
|
(ветка CodeFormer написана, но не проверена: модуль не вендорен, `D5`; проверен путь отказа)
|
||||||
|
- [x] Предупреждения (`warnings`: вход меньше 320×320, лиц не найдено, CodeFormer на CPU) в JSON-ответе.
|
||||||
|
Первые два были в Stage 1; **вход меньше 320×320 и CodeFormer на CPU добавлены при приёмке
|
||||||
|
Stage 3** — их не было в коде
|
||||||
|
- [x] **Приёмка:** портрет 640×480 в `face` → `faces_found=6`, лицо резче; фото без лиц → `faces_found=0`
|
||||||
|
и результат не хуже `x2plus`; искусственный OOM (`PHOTO_AI_TILE=2048` на 4 ГБ) деградирует до CPU
|
||||||
|
без падения сервиса.
|
||||||
|
|
||||||
|
### Журнал раздела 3 (проверено 2026-09-29)
|
||||||
|
|
||||||
|
Изменён `photo-ai/app.py` (лестница OOM на CUDA + два предупреждения) и `docker-compose.gpu.yml`
|
||||||
|
(GPU-оверрайд: cu126 + CDI). `worker.js`, `server.js`, схема БД и фронтенд не тронуты — они в Stage 4…6.
|
||||||
|
Проверки шли на работающем GPU (RTX 3050 Laptop, 4096 МБ, драйвер `615.71.09`, CUDA 12.6) — см.
|
||||||
|
журнал GPU-прогона в разделе 2.
|
||||||
|
|
||||||
|
| Проверка | Как | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| **Главная находка** | `PHOTO_AI_TILE=2048`, `x2plus face=all` на 4032×2268 | **до правки**: `500 Internal Server Error`, в логе `UnboundLocalError: local variable 'output_tile' referenced before assignment` (`realesrgan/utils.py:179`); **после**: `200`, `warnings: ["не хватило памяти при tile=2048"]`, 28.3 с |
|
||||||
|
| Причина | чтение кода `realesrgan/utils.py` и `gfpgan/utils.py` | обе библиотеки **проглатывают** `RuntimeError` в своих `try/except` вокруг вызова сети (`except RuntimeError as error: print('Error', error)`) и идут дальше, поэтому `output_tile`/`restored_face` остаётся неприсвоенным. Для `run_guarded` это был уже не `RuntimeError` → ни лестницы тайлов, ни деградации на CPU, ни внятного текста |
|
||||||
|
| Как починено | `guard_forward()` в `app.py` оборачивает `forward` сетей, построенных нами (`RealESRGANer.model`, `restorer.gfpgan`, CodeFormer `net`): `RuntimeError` с признаком OOM превращается в `TileOOM` | `TileOOM` не наследует `RuntimeError`, поэтому проглатывающие `except` его пропускают; `is_oom` и `run_guarded` (обе точки) ловят `TileOOM` явно |
|
||||||
|
| Лестница тайлов на GPU | инъекция `TileOOM('CUDA out of memory')` вместо `process_image` | `tile=2048 → 1024 → 512`, затем `degrade_to_cpu` и повтор: `warnings` = 4 пункта, `device: cpu`, `half: false`; при повторе с уже-CPU — честная `500` «не хватило памяти даже при tile=512: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS» |
|
||||||
|
| `x2plus face=all`, tile=2048 | полное фото | `200`, 6 лиц, единственное предупреждение — про тайл; сервис не упал, следующие запросы проходят |
|
||||||
|
| 640×480, три режима | портрет, приведённый к 640×480 | `off` 1.0 с / `face` 3.8 с / `all` 2.2 с, `faces_found=6`, `device=cuda:0` |
|
||||||
|
| Фото без лиц | синтетический фон 800×600 | `face` → `faces_found=0`, warning «лица не найдены, фон обработан апскейлом», 0.8 с; байты результата **равны** `face=off` (39861) — без деградации качества |
|
||||||
|
| Вход меньше 320×320 | 256×256 | два предупреждения: «вход меньше 320×320 — лица могут не найтись» + «лица не найдены» |
|
||||||
|
| `strength` | `0.5` с gfpgan / `1.4` | `400` «strength применяется только к CodeFormer, для gfpgan оставьте 0.7» / `400` «strength должен быть 0..1» |
|
||||||
|
| CodeFormer | `face_model=codeformer` | `400` «модель лиц codeformer не установлена в образ, доступен gfpgan» (`D5`) |
|
||||||
|
| **I1** | 6 сценариев (`jpg`/`.jpeg`/`.png`+q70/`.webp`+q100/`x4v3`/`animevideo-v3`) на новом и на Stage 2 образе, оба на CPU | **6/6 `MATCH` байт-в-байт** (`420757881a31f12dec174e4873e260dd`, `cdbf59d1eb460d26a3b83af2e0543d82`, `654928b60ec43dbd41c6644121041a75`, `557e76d79869230058e1acf75017c670`, `9d3d731c8858bacbba1ee88bf3f373e5`) |
|
||||||
|
| I7 | `ast.parse`, поиск комментариев | чисто, комментариев 0 |
|
||||||
|
|
||||||
|
Решения и находки, которые нужно знать дальше:
|
||||||
|
|
||||||
|
- **Лестница OOM не работала ни на одном GPU** — только на CPU, где OOM приходит из нашего кода и
|
||||||
|
не проглатывается. Это наш главный аргумент за то, что GPU-ветку надо было прогнать, а не собрать.
|
||||||
|
- **`guard_forward` вешается на экземпляр** (`model.forward`), идемпотентен по флагу
|
||||||
|
`_photo_ai_guarded` и не трогает класс — обёртка не накапливается при пересборке пула. Сетей,
|
||||||
|
которые строит не мы (retinaface в facexlib, ESRGANer внутри GFPGANer), обёртка не покрывает: там
|
||||||
|
OOM уходит нашим же `except RuntimeError`, и это правильно.
|
||||||
|
- **Проглатывание в gfpgan** (`except RuntimeError` вокруг `self.gfpgan(...)`) было даже опаснее
|
||||||
|
тихого: при OOM лицо молча оставалось исходным, задание завершалось «успешно» без единого
|
||||||
|
признака в `warnings`. Теперь такой случай уходит в лестницу тайлов, а если не помогло — в
|
||||||
|
деградацию на CPU.
|
||||||
|
- **4 ГБ VRAM — это про `PHOTO_AI_LOAD_ALL` и `PHOTO_AI_TILE`, а не про лестницу.** `x2plus` и `gfpgan`
|
||||||
|
влезают (свободно ~100–300 МБ), `general-x4v3` втрое тяжелее. Проверено: после `x4v3 face=all`
|
||||||
|
поднимается `tile=2048`, но дефолтные 256 проходят без единого предупреждения.
|
||||||
|
- **`tile` по-прежнему не залипает** — после OOM в `finally` восстанавливается `PHOTO_AI_TILE`, и
|
||||||
|
`degrade_to_cpu` больше не сбрасывает операторское значение.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Журнал GPU-прогона (закрывает строку «GPU-пуск» раздела 2)
|
||||||
|
|
||||||
|
Стоп-условие снято: `nvidia-ctk` и спека `/etc/cdi/nvidia.yaml` появились на хосте, поэтому GPU-оверрейд
|
||||||
|
переведён с классического резервирования (`driver: nvidia, count: 1`) на CDI (`device_ids:
|
||||||
|
nvidia.com/gpu=all`) — так демон перезапускать не нужно, а `/etc/docker/daemon.json` на хосте нет.
|
||||||
|
|
||||||
|
| Проверка | Как | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| Сборка GPU-образа | `docker compose -f docker-compose.yml -f docker-compose.gpu.yml build photo-ai` | `whatido-photo-ai:cu126`, 12 ГБ; `torch 2.14.0+cu126`, `cuda: 12.6` — те же версии, что и в CPU-образе |
|
||||||
|
| Старт | оверрайд + `up -d photo-ai` | `healthy`, в лог `устройство: cuda:0 (NVIDIA GeForce RTX 3050 Laptop GPU), half=True` |
|
||||||
|
| `/health` | curl | `device: cuda:0`, `half: true`, `driver: 615.71.09`, `cuda: 12.6`, `vram_total_mb: 3822`, `ready: true` |
|
||||||
|
| Обратная совместимость | базовый `docker compose up -d photo-ai` без оверрайда | сервис возвращается на CPU, `PHOTO_AI_DEVICE=auto` |
|
||||||
|
|
||||||
|
Решения, которые нужно знать дальше:
|
||||||
|
|
||||||
|
- **`TORCH_VARIANT` в оверрайде захардкожен (`cu126`), а не берётся из `.env`:** `TORCH_VARIANT=cpu`
|
||||||
|
в `.env` иначе молча собрал бы GPU-конфигурацию без CUDA. Причина смены `cu124 → cu126`: в индексе
|
||||||
|
`cu124` последний torch — `2.6.0`, а `cu126` даёт ровно `2.14.0`/`0.29.0`, как CPU-образ.
|
||||||
|
- **GPU-образ тегируется отдельно** (`whatido-photo-ai:cu126`), поэтому сборка GPU-варианта не
|
||||||
|
перетирает `whatido-photo-ai:latest` и откат на CPU — обычный `docker compose up -d photo-ai`.
|
||||||
|
- **Ветка CUDA в плане остаётся непроверенной ровно в одном месте** — печать realesных GPU-байтов:
|
||||||
|
на CPU/CUDA результаты x2 совпадают побайтно, но `half=True` на CUDA считает в fp16, поэтому
|
||||||
|
байты CPU и GPU для одной картинки не равны (это не регресс I1: эталон снимается на CPU).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Stage 4. `worker.js` + `server.js`
|
||||||
|
|
||||||
|
### 7.1 `worker.js` (`createPhotoEnhanceWorker`)
|
||||||
|
|
||||||
|
- [x] Таймауты из env: `PHOTO_AI_TIMEOUT_MS` (дефолт 300000), `PHOTO_AI_FACE_TIMEOUT_MS` (дефолт 600000)
|
||||||
|
для `face != 'off'`. Таймаут выбирается на основе `params`, а не глобально.
|
||||||
|
- [x] `runAiEnhance(srcKey, params)`: отправляет `image`, `scale`, `model`, `face`, `face_model`, `strength`
|
||||||
|
из `params` (дефолты при пустых `params`); ставит `Accept: application/json`; принимает и JSON
|
||||||
|
(base64 → буфер), и сырой `image/jpeg` (старый photo-ai) без ошибки.
|
||||||
|
- [x] `503` / `Retry-After` / сетевая недоступность → `sleep` c экспоненциальной задержкой, `return false`,
|
||||||
|
**`attempts` не инкрементится**; лимит мягких повторов (например 60) → одна честная ошибка
|
||||||
|
с понятным текстом (I5).
|
||||||
|
- [x] `AbortError` от `AbortSignal.timeout` → сообщение «ИИ-сервис не ответил за N с» — это уже
|
||||||
|
«жёсткая» ошибка с попытками.
|
||||||
|
- [x] `processOne`: `action` ∈ {`ai`, `ai_face`, `ai_upscale`} → AI-путь; `enhance` → sharp.
|
||||||
|
Пустые `params` при `ai_face` → `face='face'`.
|
||||||
|
- [x] `applyResult`: в `logAudit(..., 'photo.job.preview', {...})` добавить `model`, `face`, `face_model`,
|
||||||
|
`device`, `elapsed_ms`, `faces_found`, `warnings`. Текст уведомления `photo.job.done` — по факту
|
||||||
|
режима (`job.action === 'ai_face'` → «Фото обработано нейросетью с восстановлением лиц»).
|
||||||
|
- [x] `CONFIG` воркера: + `face_model`, `default_model`, `face_timeout_ms` (попадают в
|
||||||
|
`GET /api/photo-jobs/status` → `worker.config`).
|
||||||
|
|
||||||
|
### 7.2 `server.js`
|
||||||
|
|
||||||
|
- [x] `POST /api/entries/:id/photo/enhance-ai` (`server.js:5114`): принять `{model, face, face_model, strength}`;
|
||||||
|
валидация инлайн-хелперами и явными списками (как `PUT /api/settings`), невалидное → `400`.
|
||||||
|
`params = JSON.stringify({model, face, face_model, strength})`; `action = face === 'off' ? 'ai' : 'ai_face'`.
|
||||||
|
**Пустое тело → сегодняшнее поведение** (`params = NULL`, `action='ai'`) — I2.
|
||||||
|
- Валидация вынесена **перед** запросом записи: невалидное тело → `400` независимо от наличия
|
||||||
|
записи и фото, дешевле (без похода в БД) и детерминированно.
|
||||||
|
- [x] `PHOTO_JOB_ACTIONS` (`server.js:2254`): + `'ai_face'`, `'ai_upscale'` (см. `D3`). Схема
|
||||||
|
`action VARCHAR(20)` вмещает новые значения — миграция не нужна.
|
||||||
|
- Набор вынесен на уровень модуля (`server.js:1884`) рядом с `PHOTO_AI_URL`; единственное
|
||||||
|
применение — restore-нормализация (`server.js:2301`).
|
||||||
|
- Сверка списка с тем, что реально пишет код (`INSERT INTO photo_jobs` × 5): `ai` (пустое тело
|
||||||
|
и `face=off`), `ai_face` (`parsePhotoAiRequest`), `enhance` (`swapEntryPhotoFiles`), `restore`,
|
||||||
|
`rollback` — все семь значений набора покрыты, лишних нет.
|
||||||
|
- [x] `photoAiHealth()` (см. `D2`) + проксирование в `GET /api/photo-jobs/status` → `service`
|
||||||
|
(`reachable`, `device`, `device_name`, `ready`, `vram_total_mb`, `vram_free_mb`, `models`, `face_models`,
|
||||||
|
`loaded`). Недоступен → `{reachable:false}`, ответ 200. — **сделано 2026-09-28** (контракт и проверка
|
||||||
|
в `D2`; поля `device`/`device_name`/`vram_*` появятся вместе с расширенным `/health` в Stage 1)
|
||||||
|
- [x] `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` photo-ai для оператора.
|
||||||
|
- [x] `getStackInfo()` (`server.js:769`) — блок `photo_ai` (`engine: 'Real-ESRGAN + GFPGAN'`, `driver`,
|
||||||
|
`device`, `device_name`, `vram_total_mb`, `models`). Без credentials, только hostname.
|
||||||
|
- `photoAiHealth()` получил необязательный параметр таймаута; в `getStackInfo()` вызывается с
|
||||||
|
`2000` — «Статус стека» на `settings.html` не должен висеть на `/health` фото-сервиса 5 с.
|
||||||
|
- [x] Настройки (`PUT /api/settings`, рядом `server.js:1710`): `photo_ai_face_mode` ∈ `off|face|all` (дефолт `off`),
|
||||||
|
`photo_ai_face_model` ∈ `gfpgan|codeformer` (дефолт `gfpgan`), `photo_ai_device_pref` ∈ `auto|cuda|cpu`
|
||||||
|
(дефолт `auto`, только для UI/доков — фактическое устройство задаёт контейнер).
|
||||||
|
- [x] Дефолты новых настроек: `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`),
|
||||||
|
`keys`/`defaults` в `/api/public-settings` (`server.js:1663–1664`). `photo_ai_enabled` (строка 1677)
|
||||||
|
сохранить.
|
||||||
|
- Строки добавлены и в `ensurePhotoJobsTable()` — существующая БД получает их при старте
|
||||||
|
приложения, без ручного прогона `migration.sql`.
|
||||||
|
- Дефолты читаются из env: `photo_ai_face_model` ← `PHOTO_AI_FACE_MODEL`, `photo_ai_face_mode`
|
||||||
|
← `PHOTO_AI_FACE_MODE` (обе переменные уже есть в §10), чтобы дефолты БД и compose не разошлись.
|
||||||
|
- [x] `createPhotoEnhanceWorker({...})` (`server.js:6045`): + `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`,
|
||||||
|
`defaultFaceModel: PHOTO_AI_FACE_MODEL`.
|
||||||
|
- [x] `warnings` от photo-ai сохранять в аудит/уведомление, чтобы оператор видел, что face-режим не
|
||||||
|
сработал не из-за ошибки.
|
||||||
|
- [x] **Приёмка:** задание с `face='face'` проходит `pending → processing → done`; `params` сохранены;
|
||||||
|
аудит содержит модель/устройство/время; остановка photo-ai на лету → задание дорабатывается после
|
||||||
|
возврата сервиса **без** `error` (I5); результат применяется и откатывается как раньше.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Журнал раздела 4 (проверено 2026-09-29)
|
||||||
|
|
||||||
|
Изменён `worker.js` и `server.js`. `photo-ai/app.py`, Dockerfile, compose, схема БД (кроме трёх
|
||||||
|
`INSERT` в `settings`) и фронтенд не тронуты — они в Stage 5…6. Проверки шли на живом стеке, фото-сервис
|
||||||
|
в CPU-режиме (`device: cpu`, RTX 3050 проверен отдельно в Stage 3).
|
||||||
|
|
||||||
|
| Проверка | Как | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| face-режим end-to-end | `POST …/enhance-ai {face:'face', face_model:'gfpgan'}` (запись 385) | `pending → processing → done` за 28 с; `params` в БД сохранены; результат отдаётся (200, JPEG, 283 953 Б) |
|
||||||
|
| `face` + реальное лицо | та же запись на фото с лицом (запись 384), `face='all'` | `done` за 132 с, аудит: `faces_found: 1`, `elapsed_ms: 128961`, `device: cpu`, `warnings: null` |
|
||||||
|
| **Главная находка** | первая попытка без face | аудит: `warnings: ["лица не найдены, фон обработан апскейлом"]`, `faces_found: 0` — и эта строка **видна оператору** в уведомлении, а не прячется в лог сервиса |
|
||||||
|
| Уведомление по факту режима | три задания подряд | `ai_face` → «Фото обработано нейросетью с восстановлением лиц», `ai` → «Фото обработано нейросетью»; при warnings к `body` добавляется суффикс `· лица не найдены…` |
|
||||||
|
| **I1 (worker)** | задание с `params IS NULL` (`action='ai'`, ручная вставка) против прямого raw-вызова `/enhance` без `Accept` | **sha256 совпал** (`98254b54d0ea6f456f400fdfd18f3a033291fd83eae884359cbaf8dd6e16556d`, 452 678 Б) — новый JSON-путь воркера даёт байт-в-байт результат старого сырого пути |
|
||||||
|
| **I2** | задание с `params IS NULL` | `action` остался `ai`, `params` остался `NULL`, `attempts=0`, `done`; аудит `face: off`, `faces_found: 0` |
|
||||||
|
| **I1 (эталон)** | `capture.js after-stage4` + `verify.js` | **5/5 `MATCH` байт-в-байт** |
|
||||||
|
| **I3** | `docker compose -f … -f override.yml up -d app` с `PHOTO_AI_URL=""` | `photo_ai_enabled=false`; `enhance-ai` (`{}`, `{face:'face'}`, `{model,face:'all'}`) → **503**; `/api/photo-ai/health` → 200 `{configured:false}`; `status` → 200 `service.configured=false`; `stack.photo_ai.configured=false, host=null`; 8 маршрутов API живы; воркер виден, `ai_url=""` |
|
||||||
|
| **I5 (обрыв)** | `stop photo-ai` → задание в очередь → 45 с → `start photo-ai` | в простое: `pending`, **`attempts=0`**, `error='ИИ-сервис недоступен (ENOTFOUND)'`; аудит `photo.job.soft_retry` (`soft_attempt: 1`, `soft_limit: 60`); после возврата — `done`, `attempts=0`, `error` очищен; `status` и `system-info` отдавали 200 с `reachable=false` |
|
||||||
|
| **I5 (503)** | заглушка-прокси перед сервисом: `503` + `Retry-After: 5` | `status` вернулся в `pending`, **`attempts` остался `0`**; текст сохранил и код, и заголовок, и тело ответа: `«ИИ-сервис ответил 503 (Retry-After: 5): модель x2plus ещё загружается, повторите позже»`; после переключения заглушки на реальный сервис оба задания дошли до `done` с `attempts=0` (задержка ~60 с — мягкий backoff 10 с с удвоением) |
|
||||||
|
| Валидация `enhance-ai` | 7 невалидных тел | `400` на каждое: неизвестные `model`/`face`/`face_model`, `strength` вне `0..1`, `strength` не число, `strength` при `gfpgan`, `model`+`strength` при `gfpgan` |
|
||||||
|
| Валидация настроек | 3 невалидных + 3 валидных значения | `400` / `200`; после `PUT` новые значения видны в `/api/public-settings` сразу (кэш `public-settings` сбрасывается `invalidateSettings`) |
|
||||||
|
| `GET /api/photo-ai/health` | админ / без токена | 200 с полями `device`, `device_name`, `models`, `face_models`, `loaded`, `vram_*`, `driver`, `half`, `tile`, `max_pixels`; без токена → 401 |
|
||||||
|
| `stack.photo_ai` | `/api/system-info` | `engine`, `host` (`photo-ai:8080`, тот же `hostOf`, что у `database`/`cache`/`storage`), `device`, `device_name`, `vram_*`, `models`, `face_models`, `loaded`; кредов и пароля в payload нет |
|
||||||
|
| `worker.config` | `GET /api/photo-jobs/status` | `+ face_timeout_ms: 600000`, `default_model: x2plus`, `face_model: gfpgan` рядом с `ai_timeout_ms: 300000` |
|
||||||
|
| Реальная ошибка сервиса | `face_model: 'codeformer'` (модуль не вендорен, `D5`) | `400` от сервиса дошёл до оператора текстом: `«ИИ-сервис ответил 400: модель лиц codeformer не установлена в образ, доступен gfpgan»`; `attempts=3` (жёсткая ошибка), аудит `photo.job.error` |
|
||||||
|
| I7 | `grep` по диффу | комментариев 0, `require('fs')`/`require('path')`/`uploads` в `worker.js` нет, SQL параметризован (в `settings`-вставках литералы — ключи, как у соседней `photo_worker_enabled`) |
|
||||||
|
| I6 | дифф `db/` | только `INSERT INTO settings … ON CONFLICT DO NOTHING` × 3, ни `ALTER`, ни `CREATE TABLE`; `action VARCHAR(20)` не менялся |
|
||||||
|
| Compose | `config -q` для base, `.gpu.yml`, `.minio.yml` | валидны все три |
|
||||||
|
| `api.smoketest.js` | существующий набор | 57 `PASS`, 0 `FAIL` |
|
||||||
|
|
||||||
|
Решения и находки, которые нужно знать дальше:
|
||||||
|
|
||||||
|
- **`Accept: application/json` ничего не ломает в сервисе, но меняет контракт воркера.** Ответ приходит
|
||||||
|
base64 в JSON — это в ~1.33 раза больше трафика на стыке app↔photo-ai. Оставлено осознанно: без
|
||||||
|
метаданных (`device`, `elapsed_ms`, `faces_found`, `warnings`) в аудите и уведомлении оператор
|
||||||
|
не видит, отработала ли модель. Сырой `image/jpeg` воркер по-прежнему принимает — на случай отката
|
||||||
|
сервиса на старый контракт; метаданные тогда берутся из заголовков `x-photo-ai-*`.
|
||||||
|
- **Валидация тела вынесена перед запросом записи.** Иначе невалидное тело на несуществующей записи
|
||||||
|
давало `404`, и чекбокс «невалидное → 400» нельзя было бы проверить, не заводя запись с фото.
|
||||||
|
Побочная выгода: невалидное тело не стоит похода в БД.
|
||||||
|
- **`strength` при `face_model != 'codeformer'` отвергается на входе в API**, а не на стороне сервиса.
|
||||||
|
Иначе ошибка приходила бы после трёх попыток воркера и после 2 минут ожидания — валидация в
|
||||||
|
`PUT /api/settings`-стиле дешевле и сразу видима пользователю.
|
||||||
|
- **Тело ответа при не-2xx больше не выбрасывается** (найдено на приёмке: `ИИ-сервис ответил 400`
|
||||||
|
без причины). Теперь `readErrorBody()` разбирает JSON `{error}`/`{detail}` или сырой текст и
|
||||||
|
добавляет к сообщению — именно так оператор узнал, что CodeFormer не вендорен.
|
||||||
|
- **Один и тот же таймаут на `ai` и `ai_face` был бы неверной настройкой:** `x2plus face=all` на
|
||||||
|
4032×2268 на CPU занимает ~2 мин, и при `PHOTO_AI_TIMEOUT_MS=300000` задание с лицом упало бы
|
||||||
|
в жёсткую ошибку после трёх попыток там, где GPU-оверрайд уложился бы в 28 с. Отсюда
|
||||||
|
`PHOTO_AI_FACE_TIMEOUT_MS=600000` и выбор таймаута по `params.face`.
|
||||||
|
- **`503` — не «сервис недоступен»:** `status.service.reachable` при этом `true` (сервис отвечает).
|
||||||
|
Разводить эти два состояния важно для Stage 5 — иначе UI покажет «сервис лежит» при обычной
|
||||||
|
прогревочной паузе.
|
||||||
|
- **Прокси-заглушка вместо гонки за 503.** Первый запрос к холодной модели грузит её **синхронно**
|
||||||
|
(`ModelPool.get()` → `factory()`), а `503` получают только *конкурентные* запросы к той же модели
|
||||||
|
(`key in self.loading` → `ModelNotReady`). Воркер обрабатывает одно задание за раз, поэтому в
|
||||||
|
бою такой 503 почти не воспроизводится — ветку проверили детерминированно, проксируя ответы
|
||||||
|
сервиса заглушкой с флипом, иначе проверка была бы гонкой.
|
||||||
|
- **`photo.job.soft_retry` пишется не на каждый повтор, а на 1-й и каждый 10-й** (`n === 1 || n % 10 === 0`)
|
||||||
|
— это было в коде и до Stage 4. Практическое следствие для проверок: точный текст последнего
|
||||||
|
мягкого повтора надёжнее читать из `photo_jobs.error`, а не из аудита; в журнале выше 503 виден
|
||||||
|
именно там.
|
||||||
|
- **Живой стек используется параллельно.** Во время приёмки оператор применил результат задания 102
|
||||||
|
через UI и вернул оригинал — это и есть проверка «результат применяется и откатывается как раньше»
|
||||||
|
на реальных данных (аудит `entry.photo.apply` → `entry.photo.restore_original`). Поэтому рабочие
|
||||||
|
задания и результаты оператора не тронуты: удалены только созданные тестами строки `photo_jobs`,
|
||||||
|
их аудит-строки, уведомления и файлы в S3. Одно замечание для Stage 5: `photo_ai_face_mode` и
|
||||||
|
`photo_ai_face_model` появились в `settings` у уже работающей БД — приложение создало их сам в
|
||||||
|
`ensurePhotoJobsTable()` при старте, поэтому значения по умолчанию действительны без ручного
|
||||||
|
прогона `migration.sql`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Stage 5. Фронтенд
|
||||||
|
|
||||||
|
- [x] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима
|
||||||
|
(«Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон»); значение уходит в
|
||||||
|
`POST .../enhance-ai` телом `{model, face, face_model}`. Дефолт — из `photo_ai_face_mode`
|
||||||
|
(`/api/public-settings`, `loadEnhanceEngine`); при `device === 'cpu'` — подсказка «на CPU медленно».
|
||||||
|
Кнопка по-прежнему скрыта при `photo_ai_enabled === 'false'` (I3).
|
||||||
|
- `PHOTO_AI_MODES` (`journal.js:609`) — единственная таблица режим→`{model, face}`; значение
|
||||||
|
селекта → `photoAiMode()`, `face_model` подставляется только при `face !== 'off'`.
|
||||||
|
- [x] `public/js/worker.js`: `PHOTO_ACTION_LABELS` + `ai_face: 'ИИ + лица'`, `ai_upscale: 'ИИ-апскейл'`;
|
||||||
|
в модалке сравнения — `model`, `device`, `faces_found`, `elapsed_ms` (данные из `params`/аудита, I6);
|
||||||
|
строка состояния учитывает `service.reachable === false` и `service.device`.
|
||||||
|
- [x] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект
|
||||||
|
face-модели, селект желаемого устройства + строка «фактическое: …» с подсветкой расхождения;
|
||||||
|
read-only статус (`device_name`, VRAM, список моделей) из `/api/photo-jobs/status`.
|
||||||
|
Новые id внести в `DIRTY_FIELDS` (`settings.js:1`) и в payload (`settings.js:739`).
|
||||||
|
- [x] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`.
|
||||||
|
- [x] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код).
|
||||||
|
- [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение
|
||||||
|
«Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и VRAM.
|
||||||
|
|
||||||
|
### Журнал раздела 5 (код сделан 2026-09-30, приёмка на живом стеке не гонялась)
|
||||||
|
|
||||||
|
Изменены только файлы фронтенда: `journal.html`, `worker.html`, `settings.html`, `js/journal.js`,
|
||||||
|
`js/worker.js`, `js/settings.js`, `js/audit.js`. `server.js`, `worker.js` (воркер), `photo-ai/`, compose
|
||||||
|
и схема БД не тронуты — контракт `/enhance-ai`, `/api/photo-ai/health` и `service` в
|
||||||
|
`/api/photo-jobs/status` взят из Stage 4 без изменений (I6 соблюдён: новых колонок нет, метаданные
|
||||||
|
приходят из `photo_jobs.params` и `audit_log.target`).
|
||||||
|
|
||||||
|
| Пункт | Реализация |
|
||||||
|
|---|---|
|
||||||
|
| Селект режима в журнале | `#enhanceAiMode` рядом с «🤖 ИИ»; `PHOTO_AI_MODES` → `photoAiMode()` → тело `{model, face, face_model}`; селект и кнопка скрываются вместе при `photo_ai_enabled === 'false'` (I3) |
|
||||||
|
| Дефолт режима | `defaultPhotoAiModeKey()`: `face=all → facesbg`, `face=face → faces`, иначе `x2`; значение берётся из `photo_ai_face_mode` в `loadEnhanceEngine()` |
|
||||||
|
| Подсказка про CPU | `loadPhotoAiDevice()` (админ) читает `GET /api/photo-ai/health`; при `device` с `cpu` — «Фото-ИИ считает на CPU — лицевые режимы могут занять несколько минут». Не-админу запрос не делается |
|
||||||
|
| Тексты подтверждения | `runPhotoAi()` различает апскейл («до минуты») и face-режим («несколько минут», с названием модели лиц) — по той же логике выбирается таймаут в воркере |
|
||||||
|
| Статус фото-воркера | `photoStateHint` учитывает `enabled`, `ai_configured`, `service.reachable === false` («задания ждут») и дописывает `· расчёт на <device>` |
|
||||||
|
| Метаданные в модалке | `renderPhotoJobMeta()`: `params` (модель/режим) из строки задания, `device`/`faces_found`/`elapsed_ms`/`warnings` — из `audit_log.target` по `job_id` (один запрос `GET /api/audit?action=photo.job.preview&limit=200`, мапа кэшируется) |
|
||||||
|
| Блок «Фото-ИИ» в настройках | три селекта + read-only чипы: фактическое устройство, `device_name`, VRAM, модели/модели лиц/загруженные; жёлтым подсвечивается расхождение с желаемым устройством, красным — недоступность и незаданный `PHOTO_AI_URL` |
|
||||||
|
| Карточка в «Статусе стека» | `renderStackInfo()` рисует `stack.photo_ai`: состояние (`не настроен` / `нет связи` / `модели грузятся` / `отвечает`), движок, host, устройство, fp16/fp32, VRAM, модели |
|
||||||
|
| Метки аудита | 10 новых кодов в `audit.js`: профиль и фото ученика, главное фото ученика, фото модуля, удаление и очистка уведомлений, логотип; плюс `ai_face`/`ai_upscale` в `PHOTO_ACTION_LABELS` (`worker.js`) |
|
||||||
|
| Синтаксис | `node --check` для `js/journal.js`, `js/settings.js`, `js/worker.js`, `js/audit.js` — чисто |
|
||||||
|
|
||||||
|
Замечания, важные для приёмки и для Stage 6:
|
||||||
|
|
||||||
|
- **Метаданные в UI берутся из аудита, а не из `photo_jobs`.** В `params` лежат только модель, режим,
|
||||||
|
модель лиц и `strength`; `device`, `faces_found`, `elapsed_ms` и `warnings` пишет воркер в
|
||||||
|
`audit_log.target` при `photo.job.preview`. Поэтому `GET /api/audit?action=photo.job.preview&limit=200`
|
||||||
|
— один запрос на всё открытие модалки; у заданий старше 200 последних метаданных не будет, UI честно
|
||||||
|
покажет «Метаданные обработки недоступны». Новых колонок не добавляли (I6).
|
||||||
|
- **`reachable` и «модели грузятся» — разные состояния.** Прогрев даёт `reachable: true` при
|
||||||
|
`ready: false` (см. замечание Stage 4 про 503), поэтому строка статуса показывает «Ожидание очереди»,
|
||||||
|
а не «сервис недоступен»; жёлтая подсветка в настройках — только при реальном расхождении устройства.
|
||||||
|
- **`photo_ai_device_pref` — документирующее значение.** Фактическое устройство задаёт
|
||||||
|
`PHOTO_AI_DEVICE` в контейнере, поэтому в UI это не переключатель, а сверка «желаемое vs фактическое»
|
||||||
|
с явной подсказкой, как это исправить.
|
||||||
|
- **Приёмка на живом стеке не выполнялась** в этом изменении: чекбокс приёмки выше остаётся пустым.
|
||||||
|
Проверять нужно на стеке с поднятым `photo-ai` (иначе селект и блок статуса скрыты по I3), отдельно —
|
||||||
|
расхождение `photo_ai_device_pref=cuda` при CPU-сервисе, чтобы увидеть жёлтую подсветку.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Stage 6. Документация и тесты
|
||||||
|
|
||||||
|
- [ ] `AGENTS.md`: раздел про photo-ai (реестр моделей, device-политика, новые env, GPU-override,
|
||||||
|
контракт `/enhance` и `/health`), строки в таблице «File Map» для `photo-ai/app.py`,
|
||||||
|
`photo-ai/Dockerfile`, `docker-compose.gpu.yml`.
|
||||||
|
- [ ] `README.md`: установка NVIDIA Container Toolkit, запуск с `docker-compose.gpu.yml`,
|
||||||
|
проверка `GET /api/photo-jobs/status` → `service.device`, ручной вызов `/enhance` (с учётом `D1`).
|
||||||
|
- [ ] `.env.example`: все переменные из §7 плана с комментариями.
|
||||||
|
- [ ] `api.smoketest.js` (по правилу `AGENTS.md` — контракт API фиксируется в том же изменении):
|
||||||
|
- `POST /api/entries/:id/photo/enhance-ai` с `model: 'нет такой'` → `400`;
|
||||||
|
- `face: 'face'` без `PHOTO_AI_URL` → `503` (условно: если `ai_configured === false`);
|
||||||
|
- `GET /api/photo-jobs/status` содержит ключ `service`.
|
||||||
|
- [ ] **Приёмка:** `node api.smoketest.js` проходит; `node --check server.js`, `node --check worker.js`,
|
||||||
|
`python -c "import ast;ast.parse(...)"` для `app.py`; `docker compose config` валиден для обоих
|
||||||
|
compose-файлов; документация совпадает с кодом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Новые переменные окружения (итог)
|
||||||
|
|
||||||
|
| Переменная | Умолчание | Где используется |
|
||||||
|
|---|---|---|
|
||||||
|
| `PHOTO_AI_URL` | `http://photo-ai:8080` (compose), пусто = выкл. | `server.js`, воркер |
|
||||||
|
| `PHOTO_AI_MAX_PIXELS` | `4000000` | `app.py` (алиас `MAX_INPUT_PIXELS`) |
|
||||||
|
| `PHOTO_AI_DEVICE` | `auto` | `app.py` (`auto|cuda|cpu`) |
|
||||||
|
| `PHOTO_AI_TILE` | `256` | `app.py` |
|
||||||
|
| `PHOTO_AI_FACE_MODEL` | `gfpgan` | `app.py`, дефолт воркера, дефолт `photo_ai_face_model` |
|
||||||
|
| `PHOTO_AI_LOAD_ALL` | `0` | `app.py` |
|
||||||
|
| `PHOTO_AI_FACE_MODE` | `off` | дефолт `photo_ai_face_mode` (UI) |
|
||||||
|
| `PHOTO_AI_JPEG_QUALITY` | `92` | `app.py` |
|
||||||
|
| `PHOTO_AI_TIMEOUT_MS` | `300000` | `worker.js` (раньше хардкод — сделан в Stage 4) |
|
||||||
|
| `PHOTO_AI_FACE_TIMEOUT_MS` | `600000` | `worker.js` |
|
||||||
|
| `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | `worker.js` — лимит мягких повторов (I5) |
|
||||||
|
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | `worker.js` — первая пауза мягкого повтора |
|
||||||
|
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | `worker.js` — потолок паузы |
|
||||||
|
| `PHOTO_AI_MODELS_DIR` | `/models` | `app.py` (внутри контейнера) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Порядок и правила выполнения
|
||||||
|
|
||||||
|
- [x] Этапы выполняются строго 0 → 1 → 2 → 3 → 4 → 5 → 6. Каждый этап закрывается своей приёмкой
|
||||||
|
**до** начала следующего. (0–4 закрыты, см. журналы разделов)
|
||||||
|
- [x] Каждый завершённый чекбокс отмечать (`- [x]`) в этом файле по мере выполнения.
|
||||||
|
- [ ] Изменения в `AGENTS.md`/`.env.example`/`README.md` делать **в том же** изменении, что и код,
|
||||||
|
который они описывают (правило `AGENTS.md` про рассинхрон документации).
|
||||||
|
- [ ] Коммит на этап, а не на весь план: `app.py`+`Dockerfile`+compose → воркер/сервер → фронт → доки/тесты.
|
||||||
|
- [ ] Стоп-условия (сообщить оператору, не «чинить» самостоятельно): требуется `sudo` на хосте;
|
||||||
|
требуется установка `nvidia-container-toolkit`; нашёлся конфликт миграции БД; текущий
|
||||||
|
`/enhance` перестал давать эталонный результат (I1).
|
||||||
|
|
||||||
|
## 12. Готово, когда
|
||||||
|
|
||||||
|
- [x] `photo-ai` стартует на CPU и на CUDA, устройство выбирается автоматически, `/health` показывает
|
||||||
|
фактическое устройство, имя GPU, VRAM, доступные и загруженные модели. — **проверено на обоих**
|
||||||
|
(CPU на текущем стеке, CUDA — журнал GPU-прогона в разделе 2)
|
||||||
|
- [x] Сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему. — **I1/I2 в Stage 4**:
|
||||||
|
байт-в-байт с raw-вызовом и 5/5 `MATCH` эталона
|
||||||
|
- [ ] Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
|
||||||
|
- [ ] Face-задание проходит полный цикл, результат виден в сравнении, применяется и откатывается.
|
||||||
|
- [ ] Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` не ломают приложение.
|
||||||
|
- [ ] `node api.smoketest.js` проходит; `AGENTS.md`, `README.md`, `.env.example` описывают новые
|
||||||
|
переменные и GPU-запуск.
|
||||||
@@ -123,6 +123,27 @@ async function main() {
|
|||||||
const restoredCheck = await api('/api/public-settings');
|
const restoredCheck = await api('/api/public-settings');
|
||||||
ok('cache invalidation: restore visible', restoredCheck.data.system_name === before.data.system_name, restoredCheck.data.system_name);
|
ok('cache invalidation: restore visible', restoredCheck.data.system_name === before.data.system_name, restoredCheck.data.system_name);
|
||||||
|
|
||||||
|
const tzBad = await api('/api/settings', { token, method: 'PUT', body: { settings: { timezone: 'Nope/Nope' } } });
|
||||||
|
ok('timezone: невалидная зона отклонена', tzBad.status === 400, tzBad.status);
|
||||||
|
const fmtBad = await api('/api/settings', { token, method: 'PUT', body: { settings: { time_format: '36h' } } });
|
||||||
|
ok('time_format: невалидный формат отклонён', fmtBad.status === 400, fmtBad.status);
|
||||||
|
const pubTz = await api('/api/public-settings');
|
||||||
|
ok('public-settings отдаёт timezone/time_format', !!pubTz.data.timezone && ['24h', '12h'].includes(pubTz.data.time_format), {
|
||||||
|
timezone: pubTz.data.timezone,
|
||||||
|
time_format: pubTz.data.time_format,
|
||||||
|
});
|
||||||
|
|
||||||
|
const noFilter = await api('/api/files?limit=5', { token });
|
||||||
|
ok('files без фильтров: 200 (без висящих bind-параметров)', noFilter.status === 200, noFilter.status);
|
||||||
|
const withFilter = await api('/api/files?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
|
||||||
|
ok('files с фильтром дат: 200', withFilter.status === 200, withFilter.status);
|
||||||
|
const entriesNoFilter = await api('/api/entries?limit=5', { token });
|
||||||
|
ok('entries без фильтров: 200', entriesNoFilter.status === 200, entriesNoFilter.status);
|
||||||
|
const entriesWithFilter = await api('/api/entries?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
|
||||||
|
ok('entries с фильтром дат: 200', entriesWithFilter.status === 200, entriesWithFilter.status);
|
||||||
|
const photosFilter = await api('/api/photos?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
|
||||||
|
ok('photos с фильтром дат: 200', photosFilter.status === 200, photosFilter.status);
|
||||||
|
|
||||||
const groupCountBefore = Array.isArray(groups.data) ? groups.data.length : null;
|
const groupCountBefore = Array.isArray(groups.data) ? groups.data.length : null;
|
||||||
const bypass = await api('/api/groups', { token, headers: {} });
|
const bypass = await api('/api/groups', { token, headers: {} });
|
||||||
ok('groups scoped by role differ or equal', Array.isArray(bypass.data));
|
ok('groups scoped by role differ or equal', Array.isArray(bypass.data));
|
||||||
@@ -137,6 +158,122 @@ async function main() {
|
|||||||
reader.cancel().catch(() => {});
|
reader.cancel().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notifyNoAuth = await api('/api/notifications');
|
||||||
|
ok('notifications: список без токена -> 401', notifyNoAuth.status === 401, notifyNoAuth.status);
|
||||||
|
const notifyList = await api('/api/notifications?limit=5', { token });
|
||||||
|
ok('notifications: список -> 200', notifyList.status === 200 && Array.isArray(notifyList.data.items) && typeof notifyList.data.unread === 'number', notifyList.status);
|
||||||
|
const notifyMeta = await api('/api/notifications/meta', { token });
|
||||||
|
ok('notifications: meta перечисляет типы событий', notifyMeta.status === 200 && Array.isArray(notifyMeta.data.types) && notifyMeta.data.types.length > 0, notifyMeta.status);
|
||||||
|
ok('notifications: meta содержит тип ip.ban', Boolean((notifyMeta.data.types || []).find(t => t.type === 'ip.ban')), (notifyMeta.data.types || []).map(t => t.type));
|
||||||
|
const notifyCreate = await api('/api/notifications/test', { token, method: 'POST' });
|
||||||
|
ok('notifications: тестовое уведомление создано', notifyCreate.status === 200 && notifyCreate.data.id > 0 && notifyCreate.data.delivered === true, notifyCreate.data);
|
||||||
|
const notifyUnread = await api('/api/notifications?unread=1', { token });
|
||||||
|
ok('notifications: непрочитанные растут', notifyUnread.data.unread >= 1, notifyUnread.data.unread);
|
||||||
|
const notifyRead = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
|
||||||
|
ok('notifications: отметить уведомление прочитанным', notifyRead.status === 200, notifyRead.status);
|
||||||
|
const notifyReadAll = await api('/api/notifications/read-all', { token, method: 'POST' });
|
||||||
|
ok('notifications: отметить всё прочитанным', notifyReadAll.status === 200 && notifyReadAll.data.unread === 0, notifyReadAll.data);
|
||||||
|
const notifyStream = await fetch(BASE + '/api/notifications/stream?token=' + encodeURIComponent(token));
|
||||||
|
ok('notifications: SSE открывается', notifyStream.status === 200, notifyStream.status);
|
||||||
|
if (notifyStream.status === 200) {
|
||||||
|
const reader = notifyStream.body.getReader();
|
||||||
|
const first = await reader.read();
|
||||||
|
const text = new TextDecoder().decode(first.value || new Uint8Array());
|
||||||
|
ok('notifications: SSE отдаёт ready-кадр', text.includes('event: ready') || text.includes(':ok'), JSON.stringify(text.slice(0, 60)));
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
}
|
||||||
|
const notifyOff = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'false' } } });
|
||||||
|
const notifySuppressed = await api('/api/notifications/test', { token, method: 'POST' });
|
||||||
|
ok('notifications: выключенный тип не создаётся', notifyOff.status === 200 && notifySuppressed.status === 200 && notifySuppressed.data.id === null, notifySuppressed.data);
|
||||||
|
const notifyOn = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'true' } } });
|
||||||
|
ok('notifications: тип включается обратно', notifyOn.status === 200, notifyOn.status);
|
||||||
|
const notifyBadSetting = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_entry_new: 'maybe' } } });
|
||||||
|
ok('notifications: неверное значение настройки -> 400', notifyBadSetting.status === 400, notifyBadSetting.status);
|
||||||
|
const notifyClearNoAuth = await api('/api/notifications', { method: 'DELETE' });
|
||||||
|
ok('notifications: очистка без токена -> 401', notifyClearNoAuth.status === 401, notifyClearNoAuth.status);
|
||||||
|
const notifyDel = await api('/api/notifications/' + notifyCreate.data.id, { token, method: 'DELETE' });
|
||||||
|
ok('notifications: удаление уведомления', notifyDel.status === 200, notifyDel.status);
|
||||||
|
const notifyDelGone = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
|
||||||
|
ok('notifications: удалённое уведомление -> 404', notifyDelGone.status === 404, notifyDelGone.status);
|
||||||
|
|
||||||
|
// Фото-ИИ: photo_ai_enabled обязан совпадать с ai_configured — оба выводятся из
|
||||||
|
// PHOTO_AI_URL, и флаг не попадает в кэш public-settings, иначе после перезапуска
|
||||||
|
// с пустым PHOTO_AI_URL кнопка «🤖 ИИ» висела бы до истечения кэша (I3).
|
||||||
|
// enhance-ai проверяем на несуществующей записи: 503 без фото-ИИ и 404 с фото-ИИ,
|
||||||
|
// чтобы дымовой тест не создавал реальных заданий фото-воркеру.
|
||||||
|
const photoStatus = await api('/api/photo-jobs/status', { token });
|
||||||
|
ok('photo-jobs/status -> 200', photoStatus.status === 200, photoStatus.status);
|
||||||
|
ok('photo_ai_enabled согласован с ai_configured', pub.data.photo_ai_enabled === String(!!photoStatus.data.ai_configured), {
|
||||||
|
photo_ai_enabled: pub.data.photo_ai_enabled,
|
||||||
|
ai_configured: photoStatus.data.ai_configured,
|
||||||
|
});
|
||||||
|
const workerCfg = photoStatus.data.worker && photoStatus.data.worker.config;
|
||||||
|
ok('worker.config содержит лимит мягких повторов', Boolean(workerCfg && workerCfg.soft_max_retries > 0), workerCfg);
|
||||||
|
// service обязан быть health фото-сервиса, а не текстового ИИ: configured совпадает
|
||||||
|
// с ai_configured, reachable — булево, а при выключенном photo-ai сервис недоступен.
|
||||||
|
const photoSvc = photoStatus.data.service;
|
||||||
|
ok('photo-jobs/status -> service от фото-сервиса', Boolean(photoSvc) && photoSvc.configured === photoStatus.data.ai_configured && typeof photoSvc.reachable === 'boolean', {
|
||||||
|
service: photoSvc,
|
||||||
|
ai_configured: photoStatus.data.ai_configured,
|
||||||
|
});
|
||||||
|
ok('service: без photo-ai reachable=false', photoStatus.data.ai_configured === false ? photoSvc.reachable === false : typeof photoSvc.latency_ms === 'number', {
|
||||||
|
ai_configured: photoStatus.data.ai_configured,
|
||||||
|
reachable: photoSvc.reachable,
|
||||||
|
});
|
||||||
|
ok('worker.config.ai_url соответствует наличию фото-ИИ', Boolean(workerCfg) && workerCfg.ai_url === photoStatus.data.ai_url, {
|
||||||
|
worker_ai_url: workerCfg && workerCfg.ai_url,
|
||||||
|
ai_url: photoStatus.data.ai_url,
|
||||||
|
});
|
||||||
|
const enhanceAi = await api('/api/entries/99999999/photo/enhance-ai', { token, method: 'POST' });
|
||||||
|
ok('enhance-ai: 503 без photo-ai либо 404 с photo-ai (запись не существует)', (photoStatus.data.ai_configured === false && enhanceAi.status === 503) || (photoStatus.data.ai_configured === true && enhanceAi.status === 404), {
|
||||||
|
ai_configured: photoStatus.data.ai_configured,
|
||||||
|
status: enhanceAi.status,
|
||||||
|
body: enhanceAi.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отчёты о занятии: контракт проверки по шаблону и истории версий.
|
||||||
|
// Модель здесь не дёргаем (долго и нужен сервис) — проверяем постановку в очередь
|
||||||
|
// и то, что при ai_check:false текст остаётся нетронутым.
|
||||||
|
const lrGroups = await api('/api/groups', { token });
|
||||||
|
const gid = lrGroups.data[0] && lrGroups.data[0].id;
|
||||||
|
if (gid) {
|
||||||
|
const date = '2019-05-17';
|
||||||
|
await api(`/api/lesson-reports?group_id=${gid}&date_from=${date}&date_to=${date}`, { token })
|
||||||
|
.then(r => (r.data.items || []).forEach(i => api(`/api/lesson-reports/${i.id}`, { token, method: 'DELETE' })));
|
||||||
|
|
||||||
|
const plainText = 'Текст отчёта без проверки ИИ для смоук-теста.';
|
||||||
|
const plain = await api('/api/lesson-reports', {
|
||||||
|
token, method: 'POST',
|
||||||
|
body: { group_id: gid, lesson_date: date, lesson_time: '10:00', text: plainText, ai_check: false },
|
||||||
|
});
|
||||||
|
ok('lesson-report: создание без ai_check -> ai_status=none, text_original=null',
|
||||||
|
plain.status === 201 && plain.data.ai_status === 'none' && plain.data.text_original === null,
|
||||||
|
{ status: plain.status, ai_status: plain.data.ai_status });
|
||||||
|
|
||||||
|
const versions = await api(`/api/lesson-reports/${plain.data.id}/versions`, { token });
|
||||||
|
ok('lesson-report: история версий содержит исходный текст',
|
||||||
|
versions.status === 200 && Array.isArray(versions.data.items) && versions.data.items.length >= 1
|
||||||
|
&& versions.data.items.some(v => v.text === plainText && v.source === 'manual'),
|
||||||
|
{ status: versions.status, items: (versions.data.items || []).length });
|
||||||
|
|
||||||
|
const badRestore = await api(`/api/lesson-reports/${plain.data.id}/versions/99999999/restore`, { token, method: 'POST' });
|
||||||
|
ok('lesson-report: восстановление несуществующей версии -> 404', badRestore.status === 404, badRestore.status);
|
||||||
|
|
||||||
|
const noOrig = await api(`/api/lesson-reports/${plain.data.id}/ai/revert`, { token, method: 'POST' });
|
||||||
|
ok('lesson-report: откат без оригинала -> 400', noOrig.status === 400, noOrig.status);
|
||||||
|
|
||||||
|
const del = await api(`/api/lesson-reports/${plain.data.id}`, { token, method: 'DELETE' });
|
||||||
|
ok('lesson-report: удаление', del.status === 200, del.status);
|
||||||
|
} else {
|
||||||
|
ok('lesson-report: есть группа для проверки', false, 'no groups');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lessonNotifyMeta = await api('/api/notifications/meta', { token });
|
||||||
|
// /api/notifications/meta отдаёт ключи настроек (notify_<тип>), а не сами типы
|
||||||
|
ok('notifications: настройка lesson.ai.formatted заведена',
|
||||||
|
(lessonNotifyMeta.data.types || []).some(t => t.key === 'notify_lesson_ai_formatted'),
|
||||||
|
(lessonNotifyMeta.data.types || []).map(t => t.key));
|
||||||
|
|
||||||
const logout = await api('/api/auth/logout', { token, method: 'POST' });
|
const logout = await api('/api/auth/logout', { token, method: 'POST' });
|
||||||
ok('logout', logout.status === 200, logout.status);
|
ok('logout', logout.status === 200, logout.status);
|
||||||
const afterLogout = await api('/api/auth/me', { token });
|
const afterLogout = await api('/api/auth/me', { token });
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']);
|
||||||
|
const LESSON_REPORT_TEXT_MAX = 5000;
|
||||||
|
const BACKUP_FORMAT_VERSION = 2;
|
||||||
|
const BACKUP_MIN_FORMAT_VERSION = 1;
|
||||||
|
const BACKUP_TABLES = [
|
||||||
|
'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'user_branches',
|
||||||
|
'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs',
|
||||||
|
'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications',
|
||||||
|
'notification_reads', 'banned_ips',
|
||||||
|
];
|
||||||
|
const BACKUP_SEQUENCE_TABLES = [
|
||||||
|
'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos',
|
||||||
|
'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports',
|
||||||
|
'lesson_report_versions', 'audit_log', 'notifications',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isSupportedBackupVersion(data) {
|
||||||
|
if (!data || typeof data !== 'object' || !Array.isArray(data.groups)) return false;
|
||||||
|
const v = Number(data.version);
|
||||||
|
return Number.isInteger(v) && v >= BACKUP_MIN_FORMAT_VERSION && v <= BACKUP_FORMAT_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoredCounts(ndata) {
|
||||||
|
const out = {};
|
||||||
|
for (const tbl of BACKUP_TABLES) out[tbl] = Array.isArray(ndata[tbl]) ? ndata[tbl].length : 0;
|
||||||
|
out.settings = Object.keys(ndata.settings || {}).length;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_NAME = /^[\w,.()-]+$/;
|
||||||
|
|
||||||
|
function isSafeUploadPath(p) {
|
||||||
|
if (typeof p !== 'string' || !p.startsWith('/uploads/')) return false;
|
||||||
|
const name = p.slice('/uploads/'.length);
|
||||||
|
return name !== '' && !name.includes('/') && !name.includes('..') && SAFE_NAME.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqInt(v) {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isInteger(n)) throw new Error('Invalid integer');
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optInt(v, lo = -Infinity, hi = Infinity) {
|
||||||
|
if (v === null || v === undefined || v === '') return null;
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isInteger(n) || n < lo || n > hi) throw new Error('Invalid integer');
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqStr(v, max) {
|
||||||
|
if (typeof v !== 'string') throw new Error('Invalid string');
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s || s.length > max) throw new Error('Invalid string length');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optStr(v, max) {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
return reqStr(v, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optTs(v) {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v)) throw new Error('Invalid timestamp');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqTs(v) {
|
||||||
|
const s = optTs(v);
|
||||||
|
if (!s) throw new Error('Invalid timestamp');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optJsonText(v, max) {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
if (typeof v === 'object') {
|
||||||
|
if (Array.isArray(v)) throw new Error('Invalid json');
|
||||||
|
v = JSON.stringify(v);
|
||||||
|
}
|
||||||
|
const s = String(v);
|
||||||
|
if (!s || s.length > max) throw new Error('Invalid json');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqIp(v) {
|
||||||
|
const s = reqStr(v, 64);
|
||||||
|
if (!/^[0-9a-fA-F:.]+$/.test(s)) throw new Error('Invalid ip');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optTime(v) {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
if (typeof v !== 'string' || !/^\d{2}:\d{2}(:\d{2})?$/.test(v)) throw new Error('Invalid time');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optDate(v) {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new Error('Invalid date');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqDate(v) {
|
||||||
|
const s = optDate(v);
|
||||||
|
if (!s) throw new Error('Invalid date');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optBool(v) {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
return !!v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqToken(v) {
|
||||||
|
if (typeof v !== 'string' || !/^[0-9a-f]{16,64}$/.test(v)) throw new Error('Invalid token');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqUploadPath(v, max) {
|
||||||
|
if (typeof v !== 'string' || v.length > max) throw new Error('Invalid path');
|
||||||
|
if (!isSafeUploadPath(v)) throw new Error('Invalid upload path');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optUploadPath(v, max) {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
return reqUploadPath(v, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORIGINALS_PATH_RE = /^\/uploads\/\.originals\/[\w.,()-]+$/;
|
||||||
|
|
||||||
|
function optOriginalsPath(v, max) {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
if (typeof v !== 'string' || v.length > max || !ORIGINALS_PATH_RE.test(v)) throw new Error('Invalid originals path');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqPhotoRefPath(v, max) {
|
||||||
|
if (typeof v !== 'string' || v.length > max) throw new Error('Invalid photo path');
|
||||||
|
if (isSafeUploadPath(v) || ORIGINALS_PATH_RE.test(v)) return v;
|
||||||
|
throw new Error('Invalid photo path');
|
||||||
|
}
|
||||||
|
|
||||||
|
function optPhotoRefPath(v, max) {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
return reqPhotoRefPath(v, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoRefKey(p) {
|
||||||
|
if (typeof p !== 'string') return null;
|
||||||
|
if (isSafeUploadPath(p) || ORIGINALS_PATH_RE.test(p)) return p.slice('/uploads/'.length);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']);
|
||||||
|
|
||||||
|
function optAiText(v, max) {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
return reqStr(v, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqAiStatus(v, fallback) {
|
||||||
|
if (v === null || v === undefined) return fallback;
|
||||||
|
const s = String(v);
|
||||||
|
if (s === 'processing') return 'pending';
|
||||||
|
return AI_STATUSES.has(s) ? s : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILE_HREF_RE = /^(https?:\/\/|mailto:|tel:|\/|#)/i;
|
||||||
|
const PROFILE_EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/;
|
||||||
|
|
||||||
|
function profText(v, max) {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
if (typeof v !== 'string') throw new Error('Ожидалась строка');
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
if (s.length > max) throw new Error('Слишком длинное значение');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profIcon(v) {
|
||||||
|
const s = String(v || '').trim().toLowerCase();
|
||||||
|
return /^[a-z0-9-]{1,32}$/.test(s) ? s : 'link';
|
||||||
|
}
|
||||||
|
|
||||||
|
function profHref(v) {
|
||||||
|
const s = String(v || '').trim();
|
||||||
|
if (!s || s.length > 500) return null;
|
||||||
|
return (PROFILE_HREF_RE.test(s) || PROFILE_EMAIL_RE.test(s)) ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profList(v, max, fn) {
|
||||||
|
if (v === null || v === undefined) return [];
|
||||||
|
if (!Array.isArray(v)) throw new Error('Ожидался список');
|
||||||
|
const out = [];
|
||||||
|
for (const item of v.slice(0, max)) {
|
||||||
|
const row = fn(item);
|
||||||
|
if (row) out.push(row);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeStudentProfile(raw) {
|
||||||
|
if (raw === null || raw === undefined) return null;
|
||||||
|
if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Ожидался объект профиля');
|
||||||
|
const out = {
|
||||||
|
role: profText(raw.role, 200),
|
||||||
|
status: profText(raw.status, 60),
|
||||||
|
status_note: profText(raw.status_note, 120),
|
||||||
|
city: profText(raw.city, 120),
|
||||||
|
mentor: profText(raw.mentor, 150),
|
||||||
|
joined: profText(raw.joined, 120),
|
||||||
|
bio: profText(raw.bio, 2000),
|
||||||
|
quote: profText(raw.quote, 300),
|
||||||
|
tags: profList(raw.tags, 20, t => profText(t, 40)),
|
||||||
|
achievements: profList(raw.achievements, 40, a => profText(a, 200)),
|
||||||
|
contacts: profList(raw.contacts, 20, c => {
|
||||||
|
if (!c || typeof c !== 'object') return null;
|
||||||
|
const label = profText(c.label, 120);
|
||||||
|
if (!label) return null;
|
||||||
|
return { icon: profIcon(c.icon), label, href: profHref(c.href) };
|
||||||
|
}),
|
||||||
|
skills: profList(raw.skills, 80, s => {
|
||||||
|
if (!s || typeof s !== 'object') return null;
|
||||||
|
const name = profText(s.name, 120);
|
||||||
|
if (!name) return null;
|
||||||
|
const value = (s.value === null || s.value === undefined || s.value === '') ? null : optInt(s.value, 0, 100);
|
||||||
|
return { group: profText(s.group, 80) || 'Навыки', name, level: profText(s.level, 40), value };
|
||||||
|
}),
|
||||||
|
experience: profList(raw.experience, 30, e => {
|
||||||
|
if (!e || typeof e !== 'object') return null;
|
||||||
|
const title = profText(e.title, 160);
|
||||||
|
if (!title) return null;
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
company: profText(e.company, 160),
|
||||||
|
period: profText(e.period, 80),
|
||||||
|
date: profText(e.date, 40),
|
||||||
|
badge: profText(e.badge, 40),
|
||||||
|
description: profText(e.description, 800),
|
||||||
|
tags: profList(e.tags, 10, t => profText(t, 40)),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
education: profList(raw.education, 60, m => {
|
||||||
|
if (!m || typeof m !== 'object') return null;
|
||||||
|
const module = profText(m.module, 200);
|
||||||
|
if (!module) return null;
|
||||||
|
const progress = (m.progress === null || m.progress === undefined || m.progress === '') ? null : optInt(m.progress, 0, 100);
|
||||||
|
return { module, progress, grade: profText(m.grade, 80), teacher: profText(m.teacher, 150) };
|
||||||
|
}),
|
||||||
|
stats: profList(raw.stats, 12, s => {
|
||||||
|
if (!s || typeof s !== 'object') return null;
|
||||||
|
const label = profText(s.label, 80);
|
||||||
|
const value = (s.value === null || s.value === undefined) ? null : String(s.value).trim().slice(0, 20);
|
||||||
|
if (!label || !value) return null;
|
||||||
|
return {
|
||||||
|
icon: profIcon(s.icon || 'star'),
|
||||||
|
value,
|
||||||
|
suffix: profText(s.suffix, 20),
|
||||||
|
label,
|
||||||
|
hint: profText(s.hint, 120),
|
||||||
|
delta: profText(s.delta, 60),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const hasData = Object.values(out).some(v => (Array.isArray(v) ? v.length > 0 : v !== null));
|
||||||
|
return hasData ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRestoreData(data) {
|
||||||
|
const groups = (data.groups || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
name: reqStr(x.name, 100),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
day_of_week: optInt(x.day_of_week, 0, 6),
|
||||||
|
time_start: optTime(x.time_start),
|
||||||
|
time_end: optTime(x.time_end),
|
||||||
|
branch_id: optInt(x.branch_id, 0, 2147483647),
|
||||||
|
tutor_id: optInt(x.tutor_id, 0, 2147483647),
|
||||||
|
cover_path: optUploadPath(x.cover_path, 255),
|
||||||
|
deleted_at: optTs(x.deleted_at),
|
||||||
|
purge_at: optTs(x.purge_at),
|
||||||
|
}));
|
||||||
|
const students = (data.students || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
name: reqStr(x.name, 150),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
group_id: optInt(x.group_id, 0, 2147483647),
|
||||||
|
photo_path: optUploadPath(x.photo_path, 255),
|
||||||
|
profile: sanitizeStudentProfile(x.profile),
|
||||||
|
}));
|
||||||
|
const entries = (data.entries || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
student_name: reqStr(x.student_name, 150),
|
||||||
|
group_id: reqInt(x.group_id),
|
||||||
|
module_id: optInt(x.module_id, 0, 2147483647),
|
||||||
|
description: reqStr(x.description, 100000),
|
||||||
|
description_original: optAiText(x.description_original, 100000) ?? reqStr(x.description, 100000),
|
||||||
|
description_ai: optAiText(x.description_ai, 100000),
|
||||||
|
ai_status: reqAiStatus(x.ai_status, 'skipped'),
|
||||||
|
ai_checked_at: optTs(x.ai_checked_at),
|
||||||
|
ai_error: optAiText(x.ai_error, 500),
|
||||||
|
photo_path: optUploadPath(x.photo_path, 255),
|
||||||
|
photo_original_path: optOriginalsPath(x.photo_original_path, 255),
|
||||||
|
deleted_at: optTs(x.deleted_at),
|
||||||
|
purge_at: optTs(x.purge_at),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
|
const project_files = (data.project_files || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
entry_id: optInt(x.entry_id, 0, 2147483647),
|
||||||
|
token: reqToken(x.token),
|
||||||
|
path: reqUploadPath(x.path, 255),
|
||||||
|
name: reqStr(x.name, 255),
|
||||||
|
detached_at: optTs(x.detached_at),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
|
const branches = (data.branches || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
name: reqStr(x.name, 200),
|
||||||
|
address: optStr(x.address, 1000),
|
||||||
|
phone: optStr(x.phone, 50),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
|
const users = (data.users || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
username: reqStr(x.username, 100),
|
||||||
|
password_hash: reqStr(x.password_hash, 255),
|
||||||
|
name: optStr(x.name, 150),
|
||||||
|
role: (x.role === 'admin' || x.role === 'tutor') ? x.role : 'tutor',
|
||||||
|
is_active: !!x.is_active,
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
|
const user_branches = (data.user_branches || []).map(x => ({
|
||||||
|
user_id: reqInt(x.user_id),
|
||||||
|
branch_id: reqInt(x.branch_id),
|
||||||
|
}));
|
||||||
|
const modules = (data.modules || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
name: reqStr(x.name, 200),
|
||||||
|
lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0,
|
||||||
|
is_active: x.is_active !== false,
|
||||||
|
photo_path: optUploadPath(x.photo_path, 255),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
|
const entry_photos = (data.entry_photos || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
entry_id: reqInt(x.entry_id),
|
||||||
|
photo_path: reqUploadPath(x.photo_path, 255),
|
||||||
|
caption: optStr(x.caption, 10000),
|
||||||
|
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
|
const student_photos = (data.student_photos || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
student_id: reqInt(x.student_id),
|
||||||
|
photo_path: reqUploadPath(x.photo_path, 255),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
|
const group_photos = (data.group_photos || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
group_id: reqInt(x.group_id),
|
||||||
|
photo_path: reqUploadPath(x.photo_path, 255),
|
||||||
|
caption: optStr(x.caption, 10000),
|
||||||
|
taken_at: optDate(x.taken_at),
|
||||||
|
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
|
const share_links = (data.share_links || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
token: optStr(x.token, 40),
|
||||||
|
name: reqStr(x.name, 200),
|
||||||
|
group_id: optInt(x.group_id, 0, 2147483647),
|
||||||
|
student_name: optStr(x.student_name, 150),
|
||||||
|
date_from: optDate(x.date_from),
|
||||||
|
date_to: optDate(x.date_to),
|
||||||
|
show_student_names: optBool(x.show_student_names),
|
||||||
|
expires_at: optTs(x.expires_at),
|
||||||
|
access_password_hash: optStr(x.access_password_hash, 255),
|
||||||
|
message: optStr(x.message, 2000),
|
||||||
|
link_url: optStr(x.link_url, 500),
|
||||||
|
show_student_message: optBool(x.show_student_message),
|
||||||
|
show_entry_date: optBool(x.show_entry_date),
|
||||||
|
show_group_photos: optBool(x.show_group_photos),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
|
const lesson_reports = (data.lesson_reports || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
group_id: reqInt(x.group_id),
|
||||||
|
lesson_date: reqDate(x.lesson_date),
|
||||||
|
lesson_time: optTime(x.lesson_time),
|
||||||
|
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
|
||||||
|
text_original: optStr(x.text_original, LESSON_REPORT_TEXT_MAX),
|
||||||
|
text_ai: optStr(x.text_ai, LESSON_REPORT_TEXT_MAX),
|
||||||
|
ai_status: optStr(x.ai_status, 20),
|
||||||
|
ai_checked_at: optTs(x.ai_checked_at),
|
||||||
|
ai_error: optStr(x.ai_error, 500),
|
||||||
|
author_id: optInt(x.author_id, 0, 2147483647),
|
||||||
|
branch_id: optInt(x.branch_id, 0, 2147483647),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
updated_at: optTs(x.updated_at),
|
||||||
|
}));
|
||||||
|
const lesson_report_versions = (data.lesson_report_versions || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
lesson_report_id: reqInt(x.lesson_report_id),
|
||||||
|
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
|
||||||
|
source: optStr(x.source, 20),
|
||||||
|
author_id: optInt(x.author_id, 0, 2147483647),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
|
const settings = {};
|
||||||
|
for (const [k, v] of Object.entries(data.settings || {})) {
|
||||||
|
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
|
||||||
|
}
|
||||||
|
const audit_log = (data.audit_log || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
user_id: optInt(x.user_id, 0, 2147483647),
|
||||||
|
action: reqStr(x.action, 100),
|
||||||
|
target: optJsonText(x.target, 200000),
|
||||||
|
ip: optStr(x.ip, 45),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
|
const NOTIFICATION_LEVELS = new Set(['info', 'success', 'warning', 'critical']);
|
||||||
|
const notifications = (data.notifications || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
type: reqStr(x.type, 50),
|
||||||
|
level: (x.level && NOTIFICATION_LEVELS.has(x.level)) ? x.level : 'info',
|
||||||
|
title: reqStr(x.title, 200),
|
||||||
|
body: optStr(x.body, 2000),
|
||||||
|
link: optStr(x.link, 255),
|
||||||
|
target: optJsonText(x.target, 20000),
|
||||||
|
admin_only: !!x.admin_only,
|
||||||
|
branch_id: optInt(x.branch_id, 0, 2147483647),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
|
const notification_reads = (data.notification_reads || []).map(x => ({
|
||||||
|
user_id: reqInt(x.user_id),
|
||||||
|
notification_id: reqInt(x.notification_id),
|
||||||
|
read_at: optTs(x.read_at),
|
||||||
|
}));
|
||||||
|
const banned_ips = (data.banned_ips || []).map(x => ({
|
||||||
|
ip: reqIp(x.ip),
|
||||||
|
reason: reqStr(x.reason, 100),
|
||||||
|
banned_until: reqTs(x.banned_until),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
|
const PHOTO_JOB_STATUSES = new Set(['pending', 'processing', 'done', 'error', 'rejected']);
|
||||||
|
const photo_jobs = (data.photo_jobs || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
entry_id: reqInt(x.entry_id),
|
||||||
|
action: (x.action && PHOTO_JOB_ACTIONS.has(x.action)) ? x.action : 'ai',
|
||||||
|
params: optJsonText(x.params, 20000),
|
||||||
|
before_path: optPhotoRefPath(x.before_path, 255),
|
||||||
|
after_path: optPhotoRefPath(x.after_path, 255),
|
||||||
|
status: (x.status && PHOTO_JOB_STATUSES.has(x.status)) ? x.status : 'pending',
|
||||||
|
applied: !!x.applied,
|
||||||
|
attempts: optInt(x.attempts, 0, 2147483647) ?? 0,
|
||||||
|
error: optStr(x.error, 4000),
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
finished_at: optTs(x.finished_at),
|
||||||
|
}));
|
||||||
|
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports, lesson_report_versions, audit_log, notifications, notification_reads, banned_ips };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PHOTO_JOB_ACTIONS,
|
||||||
|
LESSON_REPORT_TEXT_MAX,
|
||||||
|
SAFE_NAME,
|
||||||
|
isSafeUploadPath,
|
||||||
|
photoRefKey,
|
||||||
|
sanitizeStudentProfile,
|
||||||
|
reqInt,
|
||||||
|
optInt,
|
||||||
|
reqStr,
|
||||||
|
optStr,
|
||||||
|
optTs,
|
||||||
|
reqTs,
|
||||||
|
optDate,
|
||||||
|
optUploadPath,
|
||||||
|
normalizeRestoreData,
|
||||||
|
BACKUP_FORMAT_VERSION,
|
||||||
|
BACKUP_MIN_FORMAT_VERSION,
|
||||||
|
BACKUP_TABLES,
|
||||||
|
BACKUP_SEQUENCE_TABLES,
|
||||||
|
restoredCounts,
|
||||||
|
isSupportedBackupVersion,
|
||||||
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
const {
|
||||||
|
BACKUP_FORMAT_VERSION,
|
||||||
|
BACKUP_TABLES,
|
||||||
|
BACKUP_SEQUENCE_TABLES,
|
||||||
|
isSupportedBackupVersion,
|
||||||
|
restoredCounts,
|
||||||
|
isSafeUploadPath,
|
||||||
|
normalizeRestoreData,
|
||||||
|
} = require('./backup-restore');
|
||||||
|
|
||||||
|
let failed = 0;
|
||||||
|
function ok(label, cond, extra) {
|
||||||
|
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined && !cond ? ' -> ' + JSON.stringify(extra) : ''}`);
|
||||||
|
if (!cond) failed++;
|
||||||
|
}
|
||||||
|
function throws(label, fn) {
|
||||||
|
let threw = false;
|
||||||
|
try { fn(); } catch (e) { threw = true; }
|
||||||
|
ok(label, threw);
|
||||||
|
}
|
||||||
|
|
||||||
|
ok('формат 1 (старый архив) поддерживается', isSupportedBackupVersion({ version: 1, groups: [] }));
|
||||||
|
ok(`формат ${BACKUP_FORMAT_VERSION} поддерживается`, isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION, groups: [] }));
|
||||||
|
ok('формат 0 отклоняется', !isSupportedBackupVersion({ version: 0, groups: [] }));
|
||||||
|
ok('формат из будущего отклоняется', !isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION + 1, groups: [] }));
|
||||||
|
ok('без groups отклоняется', !isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION }));
|
||||||
|
ok('не объект отклоняется', !isSupportedBackupVersion(null));
|
||||||
|
|
||||||
|
ok('в бэкап входят audit_log/notifications/banned_ips',
|
||||||
|
['audit_log', 'notifications', 'notification_reads', 'banned_ips'].every(t => BACKUP_TABLES.includes(t)),
|
||||||
|
BACKUP_TABLES);
|
||||||
|
ok('sessions не попадают в бэкап', !BACKUP_TABLES.includes('sessions'));
|
||||||
|
ok('sequences сбрасываются для audit_log и notifications',
|
||||||
|
BACKUP_SEQUENCE_TABLES.includes('audit_log') && BACKUP_SEQUENCE_TABLES.includes('notifications'));
|
||||||
|
|
||||||
|
const counts = restoredCounts({ groups: [1, 2], settings: { a: '1', b: '2' }, audit_log: [1] });
|
||||||
|
ok('restoredCounts считает строки и settings', counts.groups === 2 && counts.settings === 2 && counts.audit_log === 1, counts);
|
||||||
|
ok('restoredCounts для отсутствующей таблицы = 0', restoredCounts({ groups: [] }).notifications === 0);
|
||||||
|
|
||||||
|
ok('безопасный путь загрузки принимается', isSafeUploadPath('/uploads/1700000000000-abc123.jpg'));
|
||||||
|
ok('path traversal отклоняется', !isSafeUploadPath('/uploads/../../etc/passwd'));
|
||||||
|
ok('вложенный путь отклоняется', !isSafeUploadPath('/uploads/.originals/x.jpg'));
|
||||||
|
ok('чужой префикс отклоняется', !isSafeUploadPath('/etc/passwd'));
|
||||||
|
|
||||||
|
const base = { version: BACKUP_FORMAT_VERSION, groups: [], entries: [], users: [], branches: [] };
|
||||||
|
|
||||||
|
const n = normalizeRestoreData({
|
||||||
|
...base,
|
||||||
|
groups: [{ id: 1, name: 'G', deleted_at: '2026-01-02T03:04:05.000Z', purge_at: '2026-02-03T04:05:06.000Z' }],
|
||||||
|
});
|
||||||
|
ok('groups.deleted_at больше не теряется', n.groups[0].deleted_at === '2026-01-02T03:04:05.000Z', n.groups[0]);
|
||||||
|
ok('groups.purge_at больше не теряется', n.groups[0].purge_at === '2026-02-03T04:05:06.000Z', n.groups[0]);
|
||||||
|
|
||||||
|
const e = normalizeRestoreData({
|
||||||
|
...base,
|
||||||
|
entries: [{ id: 1, student_name: 'S', group_id: 1, description: 'd', deleted_at: '2026-01-02T03:04:05.000Z', purge_at: '2026-03-04T05:06:07.000Z' }],
|
||||||
|
});
|
||||||
|
ok('entries.purge_at больше не теряется', e.entries[0].purge_at === '2026-03-04T05:06:07.000Z', e.entries[0]);
|
||||||
|
|
||||||
|
const nt = normalizeRestoreData({
|
||||||
|
...base,
|
||||||
|
notifications: [{ id: 5, type: 'entry.new', level: 'warning', title: 'T', body: 'B', link: 'l', target: { a: 1 }, admin_only: true, branch_id: 2 }],
|
||||||
|
notification_reads: [{ user_id: 1, notification_id: 5 }],
|
||||||
|
audit_log: [{ id: 7, user_id: 1, action: 'backup.download', target: { size: 5 }, ip: '1.2.3.4' }],
|
||||||
|
banned_ips: [{ ip: '203.0.113.7', reason: 'manual', banned_until: '2026-05-05T00:00:00.000Z' }],
|
||||||
|
});
|
||||||
|
ok('notifications нормализуются', nt.notifications[0].level === 'warning' && nt.notifications[0].title === 'T', nt.notifications[0]);
|
||||||
|
ok('notifications.target остаётся JSON-строкой', nt.notifications[0].target === '{"a":1}', nt.notifications[0].target);
|
||||||
|
ok('notification_reads нормализуются', nt.notification_reads[0].notification_id === 5);
|
||||||
|
ok('audit_log нормализуется', nt.audit_log[0].action === 'backup.download' && nt.audit_log[0].target === '{"size":5}', nt.audit_log[0]);
|
||||||
|
ok('banned_ips нормализуются', nt.banned_ips[0].ip === '203.0.113.7');
|
||||||
|
|
||||||
|
const badLevel = normalizeRestoreData({ ...base, notifications: [{ id: 1, type: 'x', level: 'drop-table', title: 'T' }] });
|
||||||
|
ok('неизвестный level уведомления -> info', badLevel.notifications[0].level === 'info', badLevel.notifications[0]);
|
||||||
|
|
||||||
|
throws('мусорный ip в banned_ips отклоняется', () => normalizeRestoreData({ ...base, banned_ips: [{ ip: 'не ip', reason: 'r', banned_until: '2026-01-01T00:00:00.000Z' }] }));
|
||||||
|
throws('пустой banned_until отклоняется', () => normalizeRestoreData({ ...base, banned_ips: [{ ip: '1.2.3.4', reason: 'r' }] }));
|
||||||
|
throws('пустой action в audit_log отклоняется', () => normalizeRestoreData({ ...base, audit_log: [{ id: 1, action: ' ' }] }));
|
||||||
|
throws('не-объект в notifications.target отклоняется', () => normalizeRestoreData({ ...base, notifications: [{ id: 1, type: 'x', title: 'T', target: [1, 2, 3] }] }));
|
||||||
|
throws('группа без id отклоняется', () => normalizeRestoreData({ ...base, groups: [{ name: 'G' }] }));
|
||||||
|
throws('путь вне uploads отклоняется', () => normalizeRestoreData({ ...base, students: [{ id: 1, name: 'S', photo_path: '/etc/passwd' }] }));
|
||||||
|
throws('notifications не массив отклоняется', () => normalizeRestoreData({ ...base, notifications: { nope: 1 } }));
|
||||||
|
|
||||||
|
const legacy = normalizeRestoreData({ version: 1, groups: [{ id: 1, name: 'G' }] });
|
||||||
|
ok('старый архив без новых таблиц восстанавливается', Array.isArray(legacy.audit_log) && legacy.audit_log.length === 0 && legacy.groups.length === 1, Object.keys(legacy));
|
||||||
|
|
||||||
|
console.log(failed ? `\n${failed} проверок провалено` : '\nBACKUP SELFTEST OK');
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
@@ -131,6 +131,10 @@ INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
|
|||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
|
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Moscow')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('time_format', '24h')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS share_links (
|
CREATE TABLE IF NOT EXISTS share_links (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -221,6 +225,37 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lesson_reports (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
lesson_date DATE NOT NULL,
|
||||||
|
lesson_time TIME,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
text_original TEXT,
|
||||||
|
text_ai TEXT,
|
||||||
|
ai_status VARCHAR(20) NOT NULL DEFAULT 'none',
|
||||||
|
ai_checked_at TIMESTAMPTZ,
|
||||||
|
ai_error TEXT,
|
||||||
|
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lesson_report_versions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||||
|
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS photo_jobs (
|
CREATE TABLE IF NOT EXISTS photo_jobs (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
@@ -242,6 +277,15 @@ CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
|
|||||||
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
|
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('photo_ai_face_mode', 'off')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('photo_ai_face_model', 'gfpgan')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('photo_ai_device_pref', 'auto')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
@@ -252,3 +296,90 @@ CREATE TABLE IF NOT EXISTS audit_log (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
level VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
link VARCHAR(255),
|
||||||
|
target JSONB,
|
||||||
|
admin_only BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_reads (
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
notification_id INT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||||
|
read_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, notification_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id);
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_corrected', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_error', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_photo_job_done', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_photo_job_error', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор сообщений тьютора детской IT-школы.
|
||||||
|
|
||||||
|
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
|
||||||
|
|
||||||
|
ГЛАВНОЕ:
|
||||||
|
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
|
||||||
|
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
|
||||||
|
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
|
||||||
|
|
||||||
|
СТИЛЬ И СТРУКТУРА:
|
||||||
|
- Один цельный абзац, 2–4 предложения, от третьего лица.
|
||||||
|
- Первое предложение начинай со слов «На занятии ребята …».
|
||||||
|
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
|
||||||
|
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
|
||||||
|
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
|
||||||
|
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
|
||||||
|
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
|
||||||
|
|
||||||
|
ФОРМАТ:
|
||||||
|
- Без заголовков, списков, markdown, подписей и пояснений.
|
||||||
|
- Не начинай со слов «Сегодня», «Вчера», «Дата», «Группа», «Время» и вообще не упоминай группу, дату и время занятия.
|
||||||
|
- Не начинай с «На данном занятии» или «В рамках занятия».
|
||||||
|
- Не оборачивай ответ в кавычки.
|
||||||
|
- Не добавляй лишних предложений: если исходный текст уже написан нормально, не переписывай его ради переписывания.
|
||||||
|
|
||||||
|
ПРИМЕР ПРЕОБРАЗОВАНИЯ (бери отсюда только формулировки, тему и факты примера в свой текст не переноси):
|
||||||
|
Исходный текст:
|
||||||
|
«Ребята познакомились с программой Scratch Jr, научились выбирать фон, добавлять, изменять и создавать своих персонажей. В завершении занятия они выполнили практическое индивидуальное задание по созданию собственной анимации и небольшой программы».
|
||||||
|
Хороший результат:
|
||||||
|
«На занятии ребята познакомились со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей, а также создавать своих героев. В конце занятия каждый самостоятельно выполнил небольшое практическое задание — придумал свою анимацию и собрал простую программу».
|
||||||
|
|
||||||
|
ГЛАВНОЕ ПРАВИЛО:
|
||||||
|
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|||||||
@@ -234,6 +234,15 @@ CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
|
|||||||
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
|
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('photo_ai_face_mode', 'off')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('photo_ai_face_model', 'gfpgan')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('photo_ai_device_pref', 'auto')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
|
||||||
ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false;
|
ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
@@ -258,3 +267,167 @@ CREATE TABLE IF NOT EXISTS student_photos (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
|
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
level VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
link VARCHAR(255),
|
||||||
|
target JSONB,
|
||||||
|
admin_only BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_reads (
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
notification_id INT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||||
|
read_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, notification_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id);
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_corrected', 'false') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_error', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_photo_job_done', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_photo_job_error', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_lesson_report', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lesson_reports (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
lesson_date DATE NOT NULL,
|
||||||
|
lesson_time TIME,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC);
|
||||||
|
|
||||||
|
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_original TEXT;
|
||||||
|
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_ai TEXT;
|
||||||
|
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none';
|
||||||
|
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lesson_report_versions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||||
|
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC);
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор сообщений тьютора детской IT-школы.
|
||||||
|
|
||||||
|
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
|
||||||
|
|
||||||
|
ГЛАВНОЕ:
|
||||||
|
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
|
||||||
|
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
|
||||||
|
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
|
||||||
|
|
||||||
|
СТИЛЬ И СТРУКТУРА:
|
||||||
|
- Один цельный абзац, 2–4 предложения, от третьего лица.
|
||||||
|
- Первое предложение начинай со слов «На занятии ребята …».
|
||||||
|
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
|
||||||
|
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
|
||||||
|
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
|
||||||
|
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
|
||||||
|
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
|
||||||
|
|
||||||
|
ФОРМАТ:
|
||||||
|
- Без заголовков, списков, markdown, подписей и пояснений.
|
||||||
|
- Не начинай со слов «Сегодня», «Вчера», «Дата», «Группа», «Время» и вообще не упоминай группу, дату и время занятия.
|
||||||
|
- Не начинай с «На данном занятии» или «В рамках занятия».
|
||||||
|
- Не оборачивай ответ в кавычки.
|
||||||
|
- Не добавляй лишних предложений: если исходный текст уже написан нормально, не переписывай его ради переписывания.
|
||||||
|
|
||||||
|
ПРИМЕР ПРЕОБРАЗОВАНИЯ (бери отсюда только формулировки, тему и факты примера в свой текст не переноси):
|
||||||
|
Исходный текст:
|
||||||
|
«Ребята познакомились с программой Scratch Jr, научились выбирать фон, добавлять, изменять и создавать своих персонажей. В завершении занятия они выполнили практическое индивидуальное задание по созданию собственной анимации и небольшой программы».
|
||||||
|
Хороший результат:
|
||||||
|
«На занятии ребята познакомились со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей, а также создавать своих героев. В конце занятия каждый самостоятельно выполнил небольшое практическое задание — придумал свою анимацию и собрал простую программу».
|
||||||
|
|
||||||
|
ГЛАВНОЕ ПРАВИЛО:
|
||||||
|
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.') ON CONFLICT (key) DO NOTHING;
|
||||||
|
UPDATE settings
|
||||||
|
SET value = 'Ты — редактор сообщений тьютора детской IT-школы.
|
||||||
|
|
||||||
|
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
|
||||||
|
|
||||||
|
ГЛАВНОЕ:
|
||||||
|
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
|
||||||
|
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
|
||||||
|
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
|
||||||
|
|
||||||
|
СТИЛЬ И СТРУКТУРА:
|
||||||
|
- Один цельный абзац, 2–4 предложения, от третьего лица.
|
||||||
|
- Первое предложение начинай со слов «На занятии ребята …».
|
||||||
|
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
|
||||||
|
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
|
||||||
|
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
|
||||||
|
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
|
||||||
|
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
|
||||||
|
|
||||||
|
ФОРМАТ:
|
||||||
|
- Без заголовков, списков, markdown, подписей и пояснений.
|
||||||
|
- Не начинай со слов «Сегодня», «Вчера», «Дата», «Группа», «Время» и вообще не упоминай группу, дату и время занятия.
|
||||||
|
- Не начинай с «На данном занятии» или «В рамках занятия».
|
||||||
|
- Не оборачивай ответ в кавычки.
|
||||||
|
- Не добавляй лишних предложений: если исходный текст уже написан нормально, не переписывай его ради переписывания.
|
||||||
|
|
||||||
|
ПРИМЕР ПРЕОБРАЗОВАНИЯ (бери отсюда только формулировки, тему и факты примера в свой текст не переноси):
|
||||||
|
Исходный текст:
|
||||||
|
«Ребята познакомились с программой Scratch Jr, научились выбирать фон, добавлять, изменять и создавать своих персонажей. В завершении занятия они выполнили практическое индивидуальное задание по созданию собственной анимации и небольшой программы».
|
||||||
|
Хороший результат:
|
||||||
|
«На занятии ребята познакомились со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей, а также создавать своих героев. В конце занятия каждый самостоятельно выполнил небольшое практическое задание — придумал свою анимацию и собрал простую программу».
|
||||||
|
|
||||||
|
ГЛАВНОЕ ПРАВИЛО:
|
||||||
|
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.'
|
||||||
|
WHERE key = 'lesson_ai_prompt' AND value IN (
|
||||||
|
'Ты — редактор деловых отчётов образовательного центра.
|
||||||
|
|
||||||
|
Твоя задача — привести текст отчёта о занятии, написанный тьютором, к деловому стилю по шаблону ниже.
|
||||||
|
|
||||||
|
ШАБЛОН ДЕЛОВОГО СООБЩЕНИЯ:
|
||||||
|
Отчёт о проведённом занятии
|
||||||
|
Дата: <дата занятия>
|
||||||
|
Группа: <название группы>
|
||||||
|
Темы: <перечень тем>
|
||||||
|
Практика: <задания>
|
||||||
|
Домашнее задание: <что задано>
|
||||||
|
|
||||||
|
ПРАВИЛА:
|
||||||
|
1. Сначала сравни исходный текст с шаблоном. Если текст уже соответствует шаблону (та же структура, порядок и стиль) — верни его БЕЗ ИЗМЕНЕНИЙ, дословно.
|
||||||
|
2. Если текст не соответствует шаблону — перепиши его по шаблону, сохранив весь смысл и факты.
|
||||||
|
3. НЕ выдумывай тем, дат, заданий и оценок, которых нет в исходном тексте. Если данных нет — не добавляй раздел.
|
||||||
|
4. Обращение на «вы», без эмодзи и без восклицательных знаков, кратко и по делу.
|
||||||
|
5. Не добавляй приветствия, подписи и какие-либо пояснения.
|
||||||
|
6. Верни ТОЛЬКО итоговый текст отчёта — без кавычек, без markdown и без названия формата.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Moscow') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('time_format', '24h') ON CONFLICT (key) DO NOTHING;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Переопределение photo-ai для работы на NVIDIA GPU.
|
||||||
|
# Использование:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||||
|
#
|
||||||
|
# Требуется драйвер NVIDIA и NVIDIA Container Toolkit. GPU выдаётся контейнеру через CDI
|
||||||
|
# (device_ids ниже соответствует спеку /etc/cdi/nvidia.yaml): этот путь не требует правки
|
||||||
|
# /etc/docker/daemon.json и перезапуска демона. Если nvidia-runtime зарегистрирован в демоне
|
||||||
|
# (nvidia-ctk runtime configure --runtime=docker), вместо CDI можно вернуть классический вид
|
||||||
|
# резервирования: driver: nvidia, count: 1 — результат тот же.
|
||||||
|
# TORCH_VARIANT=cu126, а не cu124: в индексе cu124 последний torch — 2.6.0, а cu126 даёт ровно
|
||||||
|
# те же torch 2.14.0 / torchvision 0.29.0, что и CPU-образ, поэтому варианты сборки отличаются
|
||||||
|
# только CUDA-библиотеками.
|
||||||
|
# Образ тегируется отдельно (whatido-photo-ai:cu126), чтобы сборка GPU-варианта не перетирала
|
||||||
|
# CPU-образ whatido-photo-ai:latest.
|
||||||
|
# PHOTO_AI_DEVICE=cuda при недоступной CUDA не роняет сервис: app.py пишет WARN и работает
|
||||||
|
# на CPU, /health при этом отвечает 200.
|
||||||
|
services:
|
||||||
|
photo-ai:
|
||||||
|
image: whatido-photo-ai:cu126
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
TORCH_VARIANT: cu126
|
||||||
|
environment:
|
||||||
|
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-cuda}
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: cdi
|
||||||
|
device_ids:
|
||||||
|
- nvidia.com/gpu=all
|
||||||
|
capabilities: [gpu]
|
||||||
@@ -73,10 +73,15 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
|
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
|
||||||
|
UPLOAD_FILE_LIMIT_MB: ${UPLOAD_FILE_LIMIT_MB:-50}
|
||||||
|
UPLOAD_TOTAL_LIMIT_MB: ${UPLOAD_TOTAL_LIMIT_MB:-200}
|
||||||
|
UPLOAD_REQUEST_TIMEOUT_MS: ${UPLOAD_REQUEST_TIMEOUT_MS:-}
|
||||||
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
||||||
AI_PROMPT: ${AI_PROMPT:-}
|
AI_PROMPT: ${AI_PROMPT:-}
|
||||||
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
|
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
|
||||||
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
|
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
|
||||||
|
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
|
||||||
|
PHOTO_AI_FACE_TIMEOUT_MS: ${PHOTO_AI_FACE_TIMEOUT_MS:-600000}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
STORAGE_DRIVER: ${STORAGE_DRIVER:-local}
|
STORAGE_DRIVER: ${STORAGE_DRIVER:-local}
|
||||||
@@ -189,18 +194,36 @@ services:
|
|||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
|
|
||||||
# ИИ-улучшение фотографий (Real-ESRGAN x2, CPU).
|
# ИИ-улучшение фотографий (Real-ESRGAN: апскейл, денойз, восстановление лиц GFPGAN).
|
||||||
|
# Устройство выбирается автоматически (PHOTO_AI_DEVICE=auto): CUDA, если контейнеру
|
||||||
|
# выдан GPU, иначе CPU. Запуск на GPU — через docker-compose.gpu.yml.
|
||||||
# Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env.
|
# Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env.
|
||||||
|
# Порт 8081 пробрасывается только на loopback хоста — наружу ничего не публикуется,
|
||||||
|
# хостовый 8080 уже занят text-corrector. Ручные проверки: curl http://127.0.0.1:8081/health
|
||||||
photo-ai:
|
photo-ai:
|
||||||
build: ./photo-ai
|
build: ./photo-ai
|
||||||
container_name: photo-ai
|
container_name: photo-ai
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MODEL_PATH: /models/RealESRGAN_x2plus.pth
|
MODEL_PATH: /models/RealESRGAN_x2plus.pth
|
||||||
MAX_INPUT_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
|
PHOTO_AI_MODELS_DIR: /models
|
||||||
|
PHOTO_AI_MAX_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
|
||||||
|
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-auto}
|
||||||
|
PHOTO_AI_TILE: ${PHOTO_AI_TILE:-256}
|
||||||
|
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
|
||||||
|
PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0}
|
||||||
|
PHOTO_AI_JPEG_QUALITY: ${PHOTO_AI_JPEG_QUALITY:-92}
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
volumes:
|
volumes:
|
||||||
- photo-ai-models:/models
|
- photo-ai-models:/models
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8081:8080"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import sys, urllib.request; r = urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5); sys.exit(0 if r.status == 200 else 1)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 300s
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# TODO — Инструкция по системе WhatIDo (со скриншотами)
|
||||||
|
|
||||||
|
Статус: `☐` todo · `☑` готово · `◐` в работе
|
||||||
|
|
||||||
|
Цель: папка `instructions/` с полной русскоязычной инструкцией для администратора/преподавателя,
|
||||||
|
**каждый раздел проиллюстрирован реальными скриншотами живого стенда** (стенд поднят,
|
||||||
|
в БД 358 записей, 151 ученик, 22 группы, 72 модуля, 35 ссылок — данные реальные, не моки).
|
||||||
|
|
||||||
|
## Часть 0. Подготовка
|
||||||
|
|
||||||
|
- [x] 0.1 Поднять стенд: `docker compose ps` — app на `http://localhost:3003`
|
||||||
|
- [x] 0.2 Проверить доступность `GET /` → 200, `GET /api/public-settings` → `system_name=KIBERone`
|
||||||
|
- [x] 0.3 Получить учётку админа (`admin` / `ADMIN_PASSWORD`), `POST /api/auth/login` → токен
|
||||||
|
- [x] 0.4 Инвентаризация всех страниц `public/*.html` (меню, ID, русские подписи, роли)
|
||||||
|
- [x] 0.5 Снять инвентарь данных в БД, чтобы скриншоты были непустыми
|
||||||
|
- [x] 0.6 Создать папку `instructions/` и этот TODO
|
||||||
|
|
||||||
|
## Часть 1. Публичная часть — то, что видит ученик
|
||||||
|
|
||||||
|
- [x] 1.1 `index.html` — форма «Что мы узнали на занятии»: общий вид, шапка, футер
|
||||||
|
- [x] 1.2 Блок «Фото»: превью, кнопка «Камера», модалка камеры (снять/отмена)
|
||||||
|
- [x] 1.3 Блок «Файлы проекта»: выбор, вставка из буфера, список с размерами, очистка
|
||||||
|
- [x] 1.4 Блок полей: ФИО, Группа, Тема модуля, «Что сделал» (+ автодополнение)
|
||||||
|
- [x] 1.5 Кнопка «Отправить» + панель успеха `#sentPanel` с таймером антиспама
|
||||||
|
- [x] 1.6 Ошибки/повторная отправка, тосты
|
||||||
|
|
||||||
|
## Часть 2. Вход и оболочка админки
|
||||||
|
|
||||||
|
- [x] 2.1 `login.html` — форма входа (логин, пароль, honeypot, ошибки)
|
||||||
|
- [x] 2.2 `admin.js` — сайдбар: логотип, user-box, две группы меню, версия
|
||||||
|
- [x] 2.3 Выпадающий список уведомлений в сайдбаре + бейдж
|
||||||
|
|
||||||
|
## Часть 3. Разделы по порядку (основное → администрирование)
|
||||||
|
|
||||||
|
- [x] 3.1 **Дашборд** — плитки статистики, динамика за 14 дней, последние записи, активные группы, топ учеников, быстрые действия
|
||||||
|
- [x] 3.2 **Журнал** — фильтры, список/карточки, пагинация, карточка записи
|
||||||
|
- [x] 3.3 Журнал — модалка «Редактирование записи» (+ `✨` ИИ-исправление, `ИИ предлагает вариант`)
|
||||||
|
- [x] 3.4 Журнал — «Улучшение фото»: сравнение до/после, слайдеры, режимы ИИ, история версий
|
||||||
|
- [x] 3.5 Журнал — модалка «Создать ссылку»
|
||||||
|
- [x] 3.6 **Ученики** — список, фильтры, пагинация, кнопки действий
|
||||||
|
- [x] 3.7 Ученики — пакетное добавление, прикрепление к группе
|
||||||
|
- [x] 3.8 Ученики — «Данные профиля» (все поля отчёта)
|
||||||
|
- [x] 3.9 Ученики — «Экспорт отчёта» (ZIP)
|
||||||
|
- [x] 3.10 **Группы** — карточки, расписание, филиал/тутор
|
||||||
|
- [x] 3.11 Группы — галерея фото группы (загрузка, обложка, порядок, правка)
|
||||||
|
- [x] 3.12 Группы — «Архив файлов группы» (ZIP-выгрузка)
|
||||||
|
- [x] 3.13 **Фото** — все фото, фильтры по источнику, карточки
|
||||||
|
- [x] 3.14 **Файлы** — прикреплённые / откреплённые
|
||||||
|
- [x] 3.15 **Ссылки** — список share-ссылок, бейджи, создание
|
||||||
|
- [x] 3.16 **Корзина** — восстановление, «помеченные на удаление»
|
||||||
|
- [x] 3.17 **Темы модулей** — список, модалка модуля, пакетное добавление
|
||||||
|
- [x] 3.18 **Филиалы** — CRUD
|
||||||
|
- [x] 3.19 **Пользователи** — таблица, модалка, мультивыбор филиалов
|
||||||
|
- [x] 3.20 **Воркер ИИ** — статус, очередь, последние проверки, фото-задания, ошибки
|
||||||
|
- [x] 3.21 **Аудит** — таблица действий, модалка «Детали действия» с диффом
|
||||||
|
- [x] 3.22 **Блокировки** — таблица IP, модалка ручного бана
|
||||||
|
- [x] 3.23 **Уведомления** — полная история, фильтр, «прочитать все», «очистить всё»
|
||||||
|
- [x] 3.24 **Настройки** — 13 секций (система, стек, брендинг, антиспам, ссылки, футер, фото, фото-ИИ, уведомления, ИИ, бэкапы, корзина, блокировки)
|
||||||
|
|
||||||
|
## Часть 4. Публичные страницы по ссылкам
|
||||||
|
|
||||||
|
- [x] 4.1 `share.html` (`/s/<token>`) — карточки записей, фото группы, cookie-баннер, лайтбокс
|
||||||
|
- [x] 4.2 `share.html` — защита паролем `#passwordModal`
|
||||||
|
- [x] 4.3 `report.html` (`/r/<token>`) — публичный отчёт: hero, «Обо мне», хроника, работы, файлы, фото, контакты
|
||||||
|
- [x] 4.4 `error.html` — страница ошибки 404
|
||||||
|
|
||||||
|
## Часть 5. Сборка инструкции
|
||||||
|
|
||||||
|
- [x] 5.1 Скриншоты → `instructions/img/` с нумерацией
|
||||||
|
- [x] 5.2 `instructions/README.md` — оглавление, роли, вход, быстрый старт
|
||||||
|
- [x] 5.3 Постраничные файлы инструкции `01-…` … `05-…` с вставленными картинками
|
||||||
|
- [x] 5.4 `instructions/CHEATSHEET.md` — краткая шпаргалка + горячие клавиши/API
|
||||||
|
- [x] 5.5 Проверка: все ссылки на изображения существуют, нет битых `.md`
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
| Артефакт | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `instructions/README.md` | Оглавление + обзор системы + порядок работы |
|
||||||
|
| `instructions/01-public-form.md` | Публичная форма ученика (пошагово) |
|
||||||
|
| `instructions/02-login-shell.md` | Вход, сайдбар, уведомления |
|
||||||
|
| `instructions/03-dashboard.md` | Дашборд |
|
||||||
|
| `instructions/04-journal.md` | Журнал: фильтры, редактирование, фото-ИИ, ссылки |
|
||||||
|
| `instructions/05-students.md` | Ученики: профили, группы, экспорт |
|
||||||
|
| `instructions/06-groups-photos-files.md` | Группы, Фото, Файлы |
|
||||||
|
| `instructions/07-links-trash.md` | Ссылки, Корзина |
|
||||||
|
| `instructions/08-admin-sections.md` | Модули, Филиалы, Пользователи, Блокировки |
|
||||||
|
| `instructions/09-worker-audit-notifications.md` | Воркер ИИ, Аудит, Уведомления |
|
||||||
|
| `instructions/10-settings.md` | Настройки: все 13 секций |
|
||||||
|
| `instructions/11-share-report-pages.md` | Публичные `/s/` и `/r/` страницы |
|
||||||
|
| `instructions/CHEATSHEET.md` | Шпаргалка |
|
||||||
|
| `instructions/img/*.png` | Скриншоты |
|
||||||
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 681 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 947 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 647 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -1,14 +1,19 @@
|
|||||||
FROM python:3.10-slim
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
ARG TORCH_VARIANT=cpu
|
||||||
|
ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN pip install --no-cache-dir "typing-extensions==4.12.2" "numpy<2"
|
RUN pip install --no-cache-dir "typing-extensions==4.12.2" "numpy==2.2.6"
|
||||||
|
|
||||||
RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
RUN pip install --no-cache-dir torch torchvision --index-url ${TORCH_INDEX}
|
||||||
|
|
||||||
RUN pip install --no-cache-dir realesrgan==0.3.0 fastapi "uvicorn[standard]" python-multipart opencv-python-headless
|
RUN pip install --no-cache-dir --no-deps basicsr==1.4.2 realesrgan==0.3.0 gfpgan==1.3.8 facexlib==0.3.0 && \
|
||||||
|
pip install --no-cache-dir "numpy==2.2.6" "opencv-python-headless==5.0.0.93" addict future lmdb Pillow pyyaml \
|
||||||
|
requests scikit-image scipy tqdm filterpy numba fastapi "uvicorn[standard]" python-multipart
|
||||||
|
|
||||||
RUN BASICSR_DEG=$(python -c "import basicsr; import os; print(os.path.join(os.path.dirname(basicsr.__file__), 'data', 'degradations.py'))" 2>/dev/null) || \
|
RUN BASICSR_DEG=$(python -c "import basicsr; import os; print(os.path.join(os.path.dirname(basicsr.__file__), 'data', 'degradations.py'))" 2>/dev/null) || \
|
||||||
BASICSR_DEG=$(find /usr/local/lib/python3.10 -path "*/basicsr/data/degradations.py" 2>/dev/null | head -1) && \
|
BASICSR_DEG=$(find /usr/local/lib/python3.10 -path "*/basicsr/data/degradations.py" 2>/dev/null | head -1) && \
|
||||||
@@ -19,8 +24,10 @@ RUN BASICSR_DEG=$(python -c "import basicsr; import os; print(os.path.join(os.pa
|
|||||||
echo "basicsr degradations.py not found, skipping patch"; \
|
echo "basicsr degradations.py not found, skipping patch"; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
COPY app.py .
|
COPY app.py ./
|
||||||
|
COPY vendor/ ./vendor/
|
||||||
|
|
||||||
|
ENV PHOTO_AI_MODELS_DIR=/models
|
||||||
ENV MODEL_PATH=/models/RealESRGAN_x2plus.pth
|
ENV MODEL_PATH=/models/RealESRGAN_x2plus.pth
|
||||||
|
|
||||||
VOLUME /models
|
VOLUME /models
|
||||||
|
|||||||
@@ -1,73 +1,879 @@
|
|||||||
import io
|
|
||||||
import os
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from collections import OrderedDict
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, File, Form, UploadFile
|
|
||||||
from fastapi.responses import Response
|
|
||||||
import numpy as np
|
|
||||||
import cv2
|
import cv2
|
||||||
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||||
|
from basicsr.archs.srvgg_arch import SRVGGNetCompact
|
||||||
|
from fastapi import FastAPI, File, Form, Request, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse, Response
|
||||||
from realesrgan import RealESRGANer
|
from realesrgan import RealESRGANer
|
||||||
|
|
||||||
MODEL_PATH = os.environ.get('MODEL_PATH', '/models/RealESRGAN_x2plus.pth')
|
VENDOR_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vendor')
|
||||||
MODEL_URL = 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth'
|
if os.path.isdir(VENDOR_DIR) and VENDOR_DIR not in sys.path:
|
||||||
MAX_PIXELS = int(os.environ.get('MAX_INPUT_PIXELS', str(4_000_000)))
|
sys.path.append(VENDOR_DIR)
|
||||||
|
|
||||||
lock = threading.Lock()
|
log = logging.getLogger('photo-ai')
|
||||||
upsampler = None
|
if not log.handlers:
|
||||||
app = FastAPI()
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
|
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s'))
|
||||||
|
log.addHandler(handler)
|
||||||
|
log.setLevel(logging.INFO)
|
||||||
|
log.propagate = False
|
||||||
|
|
||||||
|
FACE_MODES = ('off', 'face', 'all')
|
||||||
|
DEFAULT_MODEL = 'x2plus'
|
||||||
|
DEFAULT_STRENGTH = 0.7
|
||||||
|
MIN_TILE = 64
|
||||||
|
MIN_FACE_SIDE = 320
|
||||||
|
POOL_LIMIT = 2
|
||||||
|
WARMUP_SIZE = 64
|
||||||
|
RETRY_AFTER_SEC = 5
|
||||||
|
|
||||||
|
|
||||||
def ensure_model():
|
def env_text(name, default=''):
|
||||||
if not os.path.exists(MODEL_PATH):
|
value = os.environ.get(name)
|
||||||
os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True)
|
if value is None:
|
||||||
tmp = MODEL_PATH + '.tmp'
|
return default
|
||||||
urllib.request.urlretrieve(MODEL_URL, tmp)
|
value = value.strip()
|
||||||
os.replace(tmp, MODEL_PATH)
|
return value or default
|
||||||
|
|
||||||
|
|
||||||
def load_model():
|
def env_flag(name, default):
|
||||||
global upsampler
|
value = env_text(name).lower()
|
||||||
ensure_model()
|
if not value:
|
||||||
model = RRDBNet(num_in_ch=3, num_out_ch=3, scale=2, num_feat=64, num_block=23, num_grow_ch=32)
|
return default
|
||||||
|
return value not in ('0', 'false', 'no', 'off')
|
||||||
|
|
||||||
|
|
||||||
|
def env_int(name, default):
|
||||||
|
value = env_text(name)
|
||||||
|
if not value:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
log.warning('%s=%r не число, беру %s', name, value, default)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(value, low, high):
|
||||||
|
return max(low, min(high, value))
|
||||||
|
|
||||||
|
|
||||||
|
DEVICE_PREF = env_text('PHOTO_AI_DEVICE', 'auto').lower()
|
||||||
|
if DEVICE_PREF not in ('auto', 'cuda', 'cpu', 'mps'):
|
||||||
|
log.warning('PHOTO_AI_DEVICE=%r неизвестно, беру auto', DEVICE_PREF)
|
||||||
|
DEVICE_PREF = 'auto'
|
||||||
|
MODELS_DIR = env_text('PHOTO_AI_MODELS_DIR', '/models')
|
||||||
|
WEIGHTS_DIR = os.path.join(MODELS_DIR, 'weights')
|
||||||
|
LEGACY_MODEL_PATH = env_text('MODEL_PATH')
|
||||||
|
MAX_PIXELS = max(env_int('PHOTO_AI_MAX_PIXELS', env_int('MAX_INPUT_PIXELS', 4000000)), 1)
|
||||||
|
BASE_TILE = max(env_int('PHOTO_AI_TILE', 256), 0)
|
||||||
|
TILE_PAD = 10
|
||||||
|
PRE_PAD = 0
|
||||||
|
LOAD_ALL = env_flag('PHOTO_AI_LOAD_ALL', False)
|
||||||
|
WARMUP = env_flag('PHOTO_AI_WARMUP', True)
|
||||||
|
DEFAULT_FACE_MODEL = env_text('PHOTO_AI_FACE_MODEL', 'gfpgan').lower()
|
||||||
|
DEFAULT_JPEG_QUALITY = clamp(env_int('PHOTO_AI_JPEG_QUALITY', 92), 70, 100)
|
||||||
|
|
||||||
|
OUTPUT_FORMATS = {
|
||||||
|
'jpg': ('jpg', 'image/jpeg', [int(cv2.IMWRITE_JPEG_QUALITY)]),
|
||||||
|
'png': ('png', 'image/png', [int(cv2.IMWRITE_PNG_COMPRESSION), 3]),
|
||||||
|
'webp': ('webp', 'image/webp', [int(cv2.IMWRITE_WEBP_QUALITY), 92]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rrdb_x2():
|
||||||
|
return RRDBNet(num_in_ch=3, num_out_ch=3, scale=2, num_feat=64, num_block=23, num_grow_ch=32)
|
||||||
|
|
||||||
|
|
||||||
|
def srvgg(num_conv):
|
||||||
|
return lambda: SRVGGNetCompact(num_in_ch=3, num_out_ch=3, num_feat=64, num_conv=num_conv,
|
||||||
|
upscale=4, act_type='prelu')
|
||||||
|
|
||||||
|
|
||||||
|
MODEL_REGISTRY = OrderedDict([
|
||||||
|
('x2plus', {
|
||||||
|
'scale': 2,
|
||||||
|
'arch': rrdb_x2,
|
||||||
|
'file': 'RealESRGAN_x2plus.pth',
|
||||||
|
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth',
|
||||||
|
'min_bytes': 60_000_000,
|
||||||
|
'alias': True,
|
||||||
|
'denoise': False,
|
||||||
|
'title': 'Универсальный апскейл x2, дефолт',
|
||||||
|
}),
|
||||||
|
('general-x4v3', {
|
||||||
|
'scale': 4,
|
||||||
|
'arch': srvgg(32),
|
||||||
|
'file': 'realesr-general-x4v3.pth',
|
||||||
|
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth',
|
||||||
|
'min_bytes': 2_000_000,
|
||||||
|
'alias': False,
|
||||||
|
'denoise': True,
|
||||||
|
'title': 'Быстрый апскейл x4 с денойзом',
|
||||||
|
'dni': {
|
||||||
|
'file': 'realesr-general-wdn-x4v3.pth',
|
||||||
|
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-wdn-x4v3.pth',
|
||||||
|
'min_bytes': 2_000_000,
|
||||||
|
'weight': 0.5,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
('animevideo-v3', {
|
||||||
|
'scale': 4,
|
||||||
|
'arch': srvgg(16),
|
||||||
|
'file': 'realesr-animevideov3.pth',
|
||||||
|
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-animevideov3.pth',
|
||||||
|
'min_bytes': 1_000_000,
|
||||||
|
'alias': False,
|
||||||
|
'denoise': False,
|
||||||
|
'title': 'Быстрый апскейл x4 для скриншотов и иллюстраций',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
FACE_REGISTRY = OrderedDict([
|
||||||
|
('gfpgan', {
|
||||||
|
'module': 'gfpgan',
|
||||||
|
'file': 'GFPGANv1.4.pth',
|
||||||
|
'url': 'https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth',
|
||||||
|
'min_bytes': 300_000_000,
|
||||||
|
'arch': 'clean',
|
||||||
|
'channel_multiplier': 2,
|
||||||
|
'strength': False,
|
||||||
|
'title': 'GFPGAN v1.4, восстановление лиц, дефолт',
|
||||||
|
}),
|
||||||
|
('codeformer', {
|
||||||
|
'module': 'codeformer',
|
||||||
|
'file': 'codeformer.pth',
|
||||||
|
'url': 'https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth',
|
||||||
|
'min_bytes': 300_000_000,
|
||||||
|
'strength': True,
|
||||||
|
'title': 'CodeFormer, восстановление лиц с регулируемой силой',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
FACEXLIB_WEIGHTS = OrderedDict([
|
||||||
|
('detection_Resnet50_Final.pth', {
|
||||||
|
'url': 'https://github.com/xinntao/facexlib/releases/download/v0.1.0/detection_Resnet50_Final.pth',
|
||||||
|
'min_bytes': 90_000_000,
|
||||||
|
}),
|
||||||
|
('parsing_parsenet.pth', {
|
||||||
|
'url': 'https://github.com/xinntao/facexlib/releases/download/v0.2.2/parsing_parsenet.pth',
|
||||||
|
'min_bytes': 70_000_000,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def cuda_ready():
|
||||||
|
try:
|
||||||
|
return bool(torch.cuda.is_available()) and torch.cuda.device_count() > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def mps_ready():
|
||||||
|
try:
|
||||||
|
return bool(torch.backends.mps.is_available())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_device_name():
|
||||||
|
return 'CPU (' + (platform.machine() or 'unknown') + ')'
|
||||||
|
|
||||||
|
|
||||||
|
def pick_device():
|
||||||
|
if DEVICE_PREF == 'cpu':
|
||||||
|
return 'cpu', False, cpu_device_name()
|
||||||
|
if DEVICE_PREF == 'mps':
|
||||||
|
if mps_ready():
|
||||||
|
return 'mps', False, 'Apple Silicon (MPS)'
|
||||||
|
log.warning('PHOTO_AI_DEVICE=mps, но MPS недоступен — работаю на CPU')
|
||||||
|
return 'cpu', False, cpu_device_name()
|
||||||
|
if DEVICE_PREF == 'cuda':
|
||||||
|
if not cuda_ready():
|
||||||
|
log.warning('PHOTO_AI_DEVICE=cuda, но CUDA недоступна — работаю на CPU')
|
||||||
|
return 'cpu', False, cpu_device_name()
|
||||||
|
return 'cuda:0', True, torch.cuda.get_device_name(0)
|
||||||
|
if cuda_ready():
|
||||||
|
return 'cuda:0', True, torch.cuda.get_device_name(0)
|
||||||
|
if mps_ready():
|
||||||
|
return 'mps', False, 'Apple Silicon (MPS)'
|
||||||
|
return 'cpu', False, cpu_device_name()
|
||||||
|
|
||||||
|
|
||||||
|
state = {'device': 'cpu', 'half': False, 'device_name': 'CPU', 'tile': BASE_TILE, 'degraded': False}
|
||||||
|
state['device'], state['half'], state['device_name'] = pick_device()
|
||||||
|
INFER_LOCK = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
class EnhanceError(Exception):
|
||||||
|
def __init__(self, message, status_code=500):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
class ModelNotReady(EnhanceError):
|
||||||
|
def __init__(self, label):
|
||||||
|
super().__init__('модель %s ещё загружается, повторите позже' % label, 503)
|
||||||
|
self.label = label
|
||||||
|
|
||||||
|
|
||||||
|
class TileOOM(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def error_response(status_code, message, headers=None):
|
||||||
|
return JSONResponse(status_code=status_code, content={'ok': False, 'error': message},
|
||||||
|
headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
def is_oom(err):
|
||||||
|
if isinstance(err, (torch.cuda.OutOfMemoryError, TileOOM)):
|
||||||
|
return True
|
||||||
|
text = str(err).lower()
|
||||||
|
return any(mark in text for mark in ('out of memory', 'not enough memory',
|
||||||
|
'alloc_cpu', "can't allocate memory"))
|
||||||
|
|
||||||
|
|
||||||
|
def guard_forward(model):
|
||||||
|
if getattr(model, '_photo_ai_guarded', False):
|
||||||
|
return model
|
||||||
|
forward = model.forward
|
||||||
|
|
||||||
|
def guarded(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return forward(*args, **kwargs)
|
||||||
|
except RuntimeError as err:
|
||||||
|
if not is_oom(err):
|
||||||
|
raise
|
||||||
|
raise TileOOM(str(err)) from err
|
||||||
|
|
||||||
|
model.forward = guarded
|
||||||
|
model._photo_ai_guarded = True
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def tile_ladder(base):
|
||||||
|
if base <= 0:
|
||||||
|
return [base]
|
||||||
|
ladder = []
|
||||||
|
for tile in (base, base // 2, base // 4):
|
||||||
|
if tile >= MIN_TILE and (not ladder or ladder[-1] != tile):
|
||||||
|
ladder.append(tile)
|
||||||
|
return ladder or [base]
|
||||||
|
|
||||||
|
|
||||||
|
def file_ok(path, min_bytes):
|
||||||
|
return os.path.isfile(path) and os.path.getsize(path) >= min_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def link_or_copy(src, dst):
|
||||||
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||||
|
try:
|
||||||
|
os.link(src, dst)
|
||||||
|
except OSError:
|
||||||
|
shutil.copyfile(src, dst)
|
||||||
|
|
||||||
|
|
||||||
|
def download_weight(url, target, min_bytes):
|
||||||
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||||
|
tmp = target + '.tmp'
|
||||||
|
log.info('качаю веса %s -> %s', url, target)
|
||||||
|
try:
|
||||||
|
urllib.request.urlretrieve(url, tmp)
|
||||||
|
except Exception as err:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise EnhanceError('не удалось скачать веса %s: %s' % (os.path.basename(target), err), 500) from err
|
||||||
|
size = os.path.getsize(tmp) if os.path.isfile(tmp) else 0
|
||||||
|
if size < min_bytes:
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise EnhanceError('веса %s повреждены: %d байт, минимум %d'
|
||||||
|
% (os.path.basename(target), size, min_bytes), 500)
|
||||||
|
os.replace(tmp, target)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_weight(name, spec, alias=''):
|
||||||
|
target = os.path.join(WEIGHTS_DIR, name)
|
||||||
|
if file_ok(target, spec['min_bytes']):
|
||||||
|
return target
|
||||||
|
if os.path.isfile(target):
|
||||||
|
log.warning('удаляю битые веса %s (%d байт)', target, os.path.getsize(target))
|
||||||
|
os.unlink(target)
|
||||||
|
if alias and file_ok(alias, spec['min_bytes']):
|
||||||
|
link_or_copy(alias, target)
|
||||||
|
log.info('веса %s взяты из существующего файла %s', name, alias)
|
||||||
|
return target
|
||||||
|
download_weight(spec['url'], target, spec['min_bytes'])
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_facexlib_weights():
|
||||||
|
for name, spec in FACEXLIB_WEIGHTS.items():
|
||||||
|
ensure_weight(name, spec)
|
||||||
|
|
||||||
|
|
||||||
|
def upsamplers_of(obj):
|
||||||
|
found = []
|
||||||
|
if isinstance(obj, RealESRGANer):
|
||||||
|
found.append(obj)
|
||||||
|
inner = getattr(obj, 'upsampler', None)
|
||||||
|
if isinstance(inner, RealESRGANer):
|
||||||
|
found.append(inner)
|
||||||
|
bg = getattr(obj, 'bg_upsampler', None)
|
||||||
|
if isinstance(bg, RealESRGANer):
|
||||||
|
found.append(bg)
|
||||||
|
elif isinstance(getattr(bg, 'upsampler', None), RealESRGANer):
|
||||||
|
found.append(bg.upsampler)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def apply_tile(obj, tile):
|
||||||
|
for upsampler in upsamplers_of(obj):
|
||||||
|
upsampler.tile_size = tile
|
||||||
|
|
||||||
|
|
||||||
|
def warm_up(runner):
|
||||||
|
if not WARMUP:
|
||||||
|
return
|
||||||
|
noise = np.random.default_rng(0).integers(0, 256, (WARMUP_SIZE, WARMUP_SIZE, 3), dtype=np.uint8)
|
||||||
|
with INFER_LOCK:
|
||||||
|
runner.enhance(noise)
|
||||||
|
|
||||||
|
|
||||||
|
class Upscaler:
|
||||||
|
def __init__(self, upsampler, outscale):
|
||||||
|
self.upsampler = upsampler
|
||||||
|
self.outscale = outscale
|
||||||
|
|
||||||
|
def enhance(self, img, outscale=None):
|
||||||
|
output, _mode = self.upsampler.enhance(img, outscale=outscale or self.outscale)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
class FaceRunner:
|
||||||
|
def __init__(self, label, outscale, bg, restore):
|
||||||
|
self.label = label
|
||||||
|
self.outscale = outscale
|
||||||
|
self.bg_upsampler = bg
|
||||||
|
self.restore = restore
|
||||||
|
|
||||||
|
def enhance(self, img, strength=DEFAULT_STRENGTH):
|
||||||
|
output, faces_found = self.restore(img, strength)
|
||||||
|
return output, faces_found
|
||||||
|
|
||||||
|
|
||||||
|
class ModelPool:
|
||||||
|
def __init__(self, limit=POOL_LIMIT):
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.entries = OrderedDict()
|
||||||
|
self.loading = OrderedDict()
|
||||||
|
self.in_use = {}
|
||||||
|
self.limit = limit
|
||||||
|
|
||||||
|
def get(self, key, label, factory):
|
||||||
|
with self.lock:
|
||||||
|
entry = self.entries.get(key)
|
||||||
|
if entry is not None:
|
||||||
|
self.entries.move_to_end(key)
|
||||||
|
self.in_use[key] = self.in_use.get(key, 0) + 1
|
||||||
|
return entry['obj']
|
||||||
|
if key in self.loading:
|
||||||
|
raise ModelNotReady(label)
|
||||||
|
self.loading[key] = label
|
||||||
|
try:
|
||||||
|
obj = factory()
|
||||||
|
warm_up(obj)
|
||||||
|
except BaseException:
|
||||||
|
with self.lock:
|
||||||
|
self.loading.pop(key, None)
|
||||||
|
raise
|
||||||
|
with self.lock:
|
||||||
|
self.loading.pop(key, None)
|
||||||
|
self.entries[key] = {'obj': obj, 'label': label}
|
||||||
|
self.in_use[key] = self.in_use.get(key, 0) + 1
|
||||||
|
apply_tile(obj, state['tile'])
|
||||||
|
self.evict_locked()
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def release(self, key):
|
||||||
|
with self.lock:
|
||||||
|
if self.in_use.get(key):
|
||||||
|
self.in_use[key] -= 1
|
||||||
|
|
||||||
|
def evict_locked(self):
|
||||||
|
while len(self.entries) > self.limit:
|
||||||
|
for key in list(self.entries):
|
||||||
|
if not self.in_use.get(key):
|
||||||
|
self.entries.pop(key, None)
|
||||||
|
self.in_use.pop(key, None)
|
||||||
|
log.info('выгружаю из кэша модель %s (LRU, лимит %d)', key, self.limit)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def acquire(self, key, label, factory):
|
||||||
|
obj = self.get(key, label, factory)
|
||||||
|
try:
|
||||||
|
yield obj
|
||||||
|
finally:
|
||||||
|
self.release(key)
|
||||||
|
|
||||||
|
def contains(self, key):
|
||||||
|
with self.lock:
|
||||||
|
return key in self.entries
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
with self.lock:
|
||||||
|
self.entries.clear()
|
||||||
|
self.in_use.clear()
|
||||||
|
|
||||||
|
def set_tile(self, tile):
|
||||||
|
with self.lock:
|
||||||
|
for entry in self.entries.values():
|
||||||
|
apply_tile(entry['obj'], tile)
|
||||||
|
|
||||||
|
def loaded_labels(self):
|
||||||
|
with self.lock:
|
||||||
|
labels = []
|
||||||
|
for entry in self.entries.values():
|
||||||
|
if entry['label'] not in labels:
|
||||||
|
labels.append(entry['label'])
|
||||||
|
return labels
|
||||||
|
|
||||||
|
def loading_labels(self):
|
||||||
|
with self.lock:
|
||||||
|
return list(self.loading.values())
|
||||||
|
|
||||||
|
|
||||||
|
pool = ModelPool()
|
||||||
|
|
||||||
|
|
||||||
|
def build_esrgan(name, denoise):
|
||||||
|
spec = MODEL_REGISTRY[name]
|
||||||
|
alias = LEGACY_MODEL_PATH if spec['alias'] else ''
|
||||||
|
path = ensure_weight(spec['file'], spec, alias)
|
||||||
|
model_path = path
|
||||||
|
dni_weight = None
|
||||||
|
if denoise and spec.get('dni'):
|
||||||
|
dni_spec = spec['dni']
|
||||||
|
dni_path = ensure_weight(dni_spec['file'], dni_spec)
|
||||||
|
weight = float(dni_spec.get('weight', 0.5))
|
||||||
|
model_path = [path, dni_path]
|
||||||
|
dni_weight = (1.0 - weight, weight)
|
||||||
upsampler = RealESRGANer(
|
upsampler = RealESRGANer(
|
||||||
scale=2,
|
scale=spec['scale'],
|
||||||
model_path=MODEL_PATH,
|
model_path=model_path,
|
||||||
model=model,
|
dni_weight=dni_weight,
|
||||||
tile=256,
|
model=guard_forward(spec['arch']()),
|
||||||
tile_pad=10,
|
tile=state['tile'],
|
||||||
pre_pad=0,
|
tile_pad=TILE_PAD,
|
||||||
half=False,
|
pre_pad=PRE_PAD,
|
||||||
device='cpu',
|
half=state['half'],
|
||||||
|
device=torch.device(state['device']),
|
||||||
)
|
)
|
||||||
|
return Upscaler(upsampler, spec['scale'])
|
||||||
|
|
||||||
|
|
||||||
|
def face_helper(outscale):
|
||||||
|
from facexlib.utils.face_restoration_helper import FaceRestoreHelper
|
||||||
|
return FaceRestoreHelper(
|
||||||
|
upscale_factor=outscale,
|
||||||
|
face_size=512,
|
||||||
|
crop_ratio=(1, 1),
|
||||||
|
det_model='retinaface_resnet50',
|
||||||
|
save_ext='png',
|
||||||
|
use_parse=True,
|
||||||
|
device=torch.device(state['device']),
|
||||||
|
model_rootpath=WEIGHTS_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def link_default_facexlib_dir():
|
||||||
|
default_dir = os.path.abspath('gfpgan/weights')
|
||||||
|
try:
|
||||||
|
if os.path.realpath(default_dir) == os.path.realpath(WEIGHTS_DIR):
|
||||||
|
return
|
||||||
|
if os.path.isdir(default_dir) and not os.path.islink(default_dir):
|
||||||
|
shutil.rmtree(default_dir, ignore_errors=True)
|
||||||
|
os.makedirs(os.path.dirname(default_dir), exist_ok=True)
|
||||||
|
if not os.path.lexists(default_dir):
|
||||||
|
os.symlink(WEIGHTS_DIR, default_dir)
|
||||||
|
log.info('каталог facexlib %s смотрит в том моделей', default_dir)
|
||||||
|
except OSError as err:
|
||||||
|
log.warning('не удалось направить %s в %s: %s', default_dir, WEIGHTS_DIR, err)
|
||||||
|
|
||||||
|
|
||||||
|
def build_face_gfpgan(spec, outscale, bg):
|
||||||
|
from gfpgan import GFPGANer
|
||||||
|
link_default_facexlib_dir()
|
||||||
|
ensure_facexlib_weights()
|
||||||
|
path = ensure_weight(spec['file'], spec)
|
||||||
|
restorer = GFPGANer(
|
||||||
|
model_path=path,
|
||||||
|
upscale=outscale,
|
||||||
|
arch=spec['arch'],
|
||||||
|
channel_multiplier=spec['channel_multiplier'],
|
||||||
|
bg_upsampler=bg.upsampler,
|
||||||
|
device=torch.device(state['device']),
|
||||||
|
)
|
||||||
|
guard_forward(restorer.gfpgan)
|
||||||
|
|
||||||
|
def restore(img, strength):
|
||||||
|
cropped, _restored, output = restorer.enhance(img, has_aligned=False, only_center_face=False,
|
||||||
|
paste_back=True)
|
||||||
|
if output is None:
|
||||||
|
output = bg.enhance(img, outscale)
|
||||||
|
return output, len(cropped)
|
||||||
|
|
||||||
|
return FaceRunner('gfpgan', outscale, bg, restore)
|
||||||
|
|
||||||
|
|
||||||
|
def build_face_codeformer(spec, outscale, bg):
|
||||||
|
from codeformer import CodeFormer
|
||||||
|
from gfpgan.utils import img2tensor, tensor2img
|
||||||
|
from torchvision.transforms.functional import normalize
|
||||||
|
ensure_facexlib_weights()
|
||||||
|
path = ensure_weight(spec['file'], spec)
|
||||||
|
net = CodeFormer(dim_embd=512, codebook_size=1024, n_head=8, n_layers=9,
|
||||||
|
connect_list=['32', '64', '128', '256'], device=state['device'], fp16=False)
|
||||||
|
net.load_state_dict(torch.load(path, map_location=lambda storage, loc: storage))
|
||||||
|
net.eval()
|
||||||
|
guard_forward(net)
|
||||||
|
helper = face_helper(outscale)
|
||||||
|
|
||||||
|
def restore(img, strength):
|
||||||
|
helper.clean_all()
|
||||||
|
helper.read_image(img)
|
||||||
|
helper.get_face_landmarks_5(only_center_face=False, eye_dist_threshold=5)
|
||||||
|
helper.align_warp_face()
|
||||||
|
for cropped in helper.cropped_faces:
|
||||||
|
tensor = img2tensor(cropped / 255., bgr2rgb=True, float32=True)
|
||||||
|
normalize(tensor, (0.5, 0.5, 0.5), (0.5, 0.5, 0.5), inplace=True)
|
||||||
|
tensor = tensor.unsqueeze(0).to(net.device)
|
||||||
|
with torch.no_grad():
|
||||||
|
output = net(tensor, w=clamp(float(strength), 0.0, 1.0))[0]
|
||||||
|
restored = tensor2img(output.squeeze(0), rgb2bgr=True, min_max=(-1, 1))
|
||||||
|
helper.add_restored_face(restored.astype('uint8'))
|
||||||
|
bg_img = bg.enhance(img, outscale)
|
||||||
|
helper.get_inverse_affine(None)
|
||||||
|
return helper.paste_faces_to_input_image(upsample_img=bg_img), len(helper.cropped_faces)
|
||||||
|
|
||||||
|
return FaceRunner('codeformer', outscale, bg, restore)
|
||||||
|
|
||||||
|
|
||||||
|
FACE_BUILDERS = {'gfpgan': build_face_gfpgan, 'codeformer': build_face_codeformer}
|
||||||
|
|
||||||
|
|
||||||
|
def face_installed(name):
|
||||||
|
module = FACE_REGISTRY[name]['module']
|
||||||
|
try:
|
||||||
|
return importlib.util.find_spec(module) is not None
|
||||||
|
except (ImportError, ValueError, AttributeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def available_face_models():
|
||||||
|
return [name for name in FACE_REGISTRY if face_installed(name)]
|
||||||
|
|
||||||
|
|
||||||
|
def build_face(name, outscale, bg):
|
||||||
|
spec = FACE_REGISTRY[name]
|
||||||
|
return FACE_BUILDERS[name](spec, outscale, bg)
|
||||||
|
|
||||||
|
|
||||||
|
def process_image(img, outscale, model_name, face, face_name, strength):
|
||||||
|
denoise = face == 'all' and bool(MODEL_REGISTRY[model_name].get('denoise'))
|
||||||
|
if face == 'off':
|
||||||
|
suffix = ':wdn' if denoise else ''
|
||||||
|
with pool.acquire('esrgan:' + model_name + suffix, model_name,
|
||||||
|
lambda: build_esrgan(model_name, denoise)) as runner:
|
||||||
|
return runner.enhance(img, outscale), 0
|
||||||
|
key = 'face:%s@%d' % (face_name, outscale)
|
||||||
|
|
||||||
|
def factory():
|
||||||
|
return build_face(face_name, outscale, build_esrgan(model_name, denoise))
|
||||||
|
|
||||||
|
with pool.acquire(key, face_name, factory) as runner:
|
||||||
|
return runner.enhance(img, strength)
|
||||||
|
|
||||||
|
|
||||||
|
def degrade_to_cpu(warnings):
|
||||||
|
if state['degraded']:
|
||||||
|
return
|
||||||
|
log.warning('устройство %s не справилось, переключаюсь на CPU', state['device'])
|
||||||
|
warnings.append('не хватило памяти на %s, обработка переведена на CPU' % state['device'])
|
||||||
|
pool.clear()
|
||||||
|
state['device'] = 'cpu'
|
||||||
|
state['half'] = False
|
||||||
|
state['device_name'] = cpu_device_name()
|
||||||
|
state['degraded'] = True
|
||||||
|
state['tile'] = BASE_TILE
|
||||||
|
|
||||||
|
|
||||||
|
def run_guarded(img, outscale, model_name, face, face_name, strength, warnings):
|
||||||
|
with INFER_LOCK:
|
||||||
|
start_tile = state['tile']
|
||||||
|
try:
|
||||||
|
for tile in tile_ladder(start_tile):
|
||||||
|
state['tile'] = tile
|
||||||
|
pool.set_tile(tile)
|
||||||
|
try:
|
||||||
|
return process_image(img, outscale, model_name, face, face_name, strength)
|
||||||
|
except (RuntimeError, TileOOM) as err:
|
||||||
|
if not is_oom(err):
|
||||||
|
raise EnhanceError('ошибка модели: %s' % err, 500) from err
|
||||||
|
log.warning('нехватка памяти при tile=%s: %s', tile, err)
|
||||||
|
warnings.append('не хватило памяти при tile=%d' % tile)
|
||||||
|
if not state['device'].startswith('cpu'):
|
||||||
|
degrade_to_cpu(warnings)
|
||||||
|
try:
|
||||||
|
return process_image(img, outscale, model_name, face, face_name, strength)
|
||||||
|
except (RuntimeError, TileOOM) as err:
|
||||||
|
if is_oom(err):
|
||||||
|
raise EnhanceError('не хватило памяти даже на CPU: %s' % err, 500) from err
|
||||||
|
raise EnhanceError('ошибка модели на CPU: %s' % err, 500) from err
|
||||||
|
raise EnhanceError('не хватило памяти даже при tile=%d: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS'
|
||||||
|
% state['tile'], 500)
|
||||||
|
finally:
|
||||||
|
state['tile'] = start_tile
|
||||||
|
pool.set_tile(start_tile)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_image(out, fmt, quality):
|
||||||
|
ext, media_type, params = fmt
|
||||||
|
if ext == 'jpg':
|
||||||
|
params = params + [quality]
|
||||||
|
ok, encoded = cv2.imencode('.' + ext, out, params)
|
||||||
|
if not ok:
|
||||||
|
raise EnhanceError('не удалось закодировать результат как %s' % ext, 500)
|
||||||
|
return encoded.tobytes(), media_type
|
||||||
|
|
||||||
|
|
||||||
|
def output_format(filename):
|
||||||
|
name = (filename or '').rsplit('/', 1)[-1]
|
||||||
|
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
|
||||||
|
if ext == 'jpeg':
|
||||||
|
ext = 'jpg'
|
||||||
|
return OUTPUT_FORMATS.get(ext, OUTPUT_FORMATS['jpg'])
|
||||||
|
|
||||||
|
|
||||||
|
def wants_json(request):
|
||||||
|
return 'application/json' in (request.headers.get('accept') or '').lower()
|
||||||
|
|
||||||
|
|
||||||
|
def driver_version():
|
||||||
|
try:
|
||||||
|
with open('/proc/driver/nvidia/version', 'r') as handle:
|
||||||
|
text = handle.read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
match = re.search(r'\d+\.\d+\.\d+', text)
|
||||||
|
return match.group(0) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def vram_total_mb():
|
||||||
|
if not state['device'].startswith('cuda'):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return round(torch.cuda.get_device_properties(0).total_memory / (1024 * 1024))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def vram_free_mb():
|
||||||
|
if not state['device'].startswith('cuda'):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
free, _total = torch.cuda.mem_get_info()
|
||||||
|
return round(free / (1024 * 1024))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate(model_name, face, face_name, strength, quality):
|
||||||
|
if model_name not in MODEL_REGISTRY:
|
||||||
|
raise EnhanceError('неизвестная модель %r, доступны: %s'
|
||||||
|
% (model_name, ', '.join(MODEL_REGISTRY)), 400)
|
||||||
|
if face not in FACE_MODES:
|
||||||
|
raise EnhanceError('неизвестный режим лиц %r, доступны: %s' % (face, ', '.join(FACE_MODES)), 400)
|
||||||
|
if quality < 70 or quality > 100:
|
||||||
|
raise EnhanceError('jpeg_quality должен быть 70..100, получено %d' % quality, 400)
|
||||||
|
if face == 'off':
|
||||||
|
return
|
||||||
|
if face_name not in FACE_REGISTRY:
|
||||||
|
raise EnhanceError('неизвестная face-модель %r, доступны: %s'
|
||||||
|
% (face_name, ', '.join(available_face_models()) or 'нет'), 400)
|
||||||
|
if not face_installed(face_name):
|
||||||
|
others = [name for name in available_face_models()]
|
||||||
|
raise EnhanceError('модель лиц %s не установлена в образ, доступен %s'
|
||||||
|
% (face_name, ', '.join(others) or 'ни один'), 400)
|
||||||
|
if strength < 0.0 or strength > 1.0:
|
||||||
|
raise EnhanceError('strength должен быть 0..1, получено %s' % strength, 400)
|
||||||
|
if not FACE_REGISTRY[face_name]['strength'] and abs(strength - DEFAULT_STRENGTH) > 1e-6:
|
||||||
|
raise EnhanceError('strength применяется только к CodeFormer, для %s оставьте %s'
|
||||||
|
% (face_name, DEFAULT_STRENGTH), 400)
|
||||||
|
|
||||||
|
|
||||||
|
def preload():
|
||||||
|
names = list(MODEL_REGISTRY) if LOAD_ALL else [DEFAULT_MODEL]
|
||||||
|
for name in names:
|
||||||
|
try:
|
||||||
|
with pool.acquire('esrgan:' + name, name, lambda n=name: build_esrgan(n, False)):
|
||||||
|
log.info('модель %s готова', name)
|
||||||
|
except Exception as err:
|
||||||
|
log.error('предзагрузка модели %s не удалась: %s', name, err)
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
@app.on_event('startup')
|
@app.on_event('startup')
|
||||||
async def startup():
|
async def startup():
|
||||||
await asyncio.to_thread(load_model)
|
log.info('устройство: %s (%s), half=%s, tile=%s, веса: %s',
|
||||||
|
state['device'], state['device_name'], state['half'], state['tile'], WEIGHTS_DIR)
|
||||||
|
threading.Thread(target=preload, name='photo-ai-preload', daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
@app.get('/health')
|
@app.get('/health')
|
||||||
def health():
|
def health():
|
||||||
return {'ok': upsampler is not None}
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'ready': pool.contains('esrgan:' + DEFAULT_MODEL),
|
||||||
|
'device': state['device'],
|
||||||
|
'device_name': state['device_name'],
|
||||||
|
'half': state['half'],
|
||||||
|
'tile': state['tile'],
|
||||||
|
'driver': driver_version(),
|
||||||
|
'cuda': torch.version.cuda,
|
||||||
|
'vram_total_mb': vram_total_mb(),
|
||||||
|
'vram_free_mb': vram_free_mb(),
|
||||||
|
'models': list(MODEL_REGISTRY),
|
||||||
|
'face_models': available_face_models(),
|
||||||
|
'loaded': pool.loaded_labels(),
|
||||||
|
'loading': pool.loading_labels(),
|
||||||
|
'max_pixels': MAX_PIXELS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/models')
|
||||||
|
def models():
|
||||||
|
loaded = pool.loaded_labels()
|
||||||
|
faces = available_face_models()
|
||||||
|
return {
|
||||||
|
'device': state['device'],
|
||||||
|
'device_name': state['device_name'],
|
||||||
|
'half': state['half'],
|
||||||
|
'tile': state['tile'],
|
||||||
|
'max_pixels': MAX_PIXELS,
|
||||||
|
'defaults': {
|
||||||
|
'model': DEFAULT_MODEL,
|
||||||
|
'scale': 2,
|
||||||
|
'face': 'off',
|
||||||
|
'face_model': DEFAULT_FACE_MODEL,
|
||||||
|
'strength': DEFAULT_STRENGTH,
|
||||||
|
'jpeg_quality': DEFAULT_JPEG_QUALITY,
|
||||||
|
},
|
||||||
|
'models': [
|
||||||
|
{
|
||||||
|
'name': name,
|
||||||
|
'title': spec['title'],
|
||||||
|
'scale': spec['scale'],
|
||||||
|
'weights': spec['file'],
|
||||||
|
'denoise': bool(spec.get('denoise')),
|
||||||
|
'loaded': name in loaded,
|
||||||
|
}
|
||||||
|
for name, spec in MODEL_REGISTRY.items()
|
||||||
|
],
|
||||||
|
'face_models': [
|
||||||
|
{
|
||||||
|
'name': name,
|
||||||
|
'title': spec['title'],
|
||||||
|
'weights': spec['file'],
|
||||||
|
'available': name in faces,
|
||||||
|
'strength': bool(spec['strength']),
|
||||||
|
'loaded': name in loaded,
|
||||||
|
}
|
||||||
|
for name, spec in FACE_REGISTRY.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post('/enhance')
|
@app.post('/enhance')
|
||||||
async def enhance(image: UploadFile = File(...), scale: int = Form(2)):
|
async def enhance(request: Request,
|
||||||
data = await image.read()
|
image: UploadFile = File(...),
|
||||||
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
|
scale: int = Form(2),
|
||||||
if img is None:
|
model: str = Form(DEFAULT_MODEL),
|
||||||
return Response('bad image', status_code=400)
|
face: str = Form('off'),
|
||||||
if img.shape[0] * img.shape[1] > MAX_PIXELS:
|
face_model: str = Form(''),
|
||||||
r = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5
|
strength: float = Form(DEFAULT_STRENGTH),
|
||||||
img = cv2.resize(img, (int(img.shape[1] * r), int(img.shape[0] * r)), interpolation=cv2.INTER_AREA)
|
jpeg_quality: int = Form(0)):
|
||||||
outscale = min(max(int(scale), 2), 4)
|
started = time.time()
|
||||||
with lock:
|
model_name = (model or DEFAULT_MODEL).strip().lower()
|
||||||
out, _ = upsampler.enhance(img, outscale=outscale)
|
face_mode = (face or 'off').strip().lower()
|
||||||
ok, enc = cv2.imencode('.jpg', out, [int(cv2.IMWRITE_JPEG_QUALITY), 92])
|
face_name = (face_model or '').strip().lower() or (
|
||||||
if not ok:
|
DEFAULT_FACE_MODEL if DEFAULT_FACE_MODEL in FACE_REGISTRY else 'gfpgan')
|
||||||
return Response('encode failed', status_code=500)
|
quality = jpeg_quality if jpeg_quality else DEFAULT_JPEG_QUALITY
|
||||||
return Response(enc.tobytes(), media_type='image/jpeg')
|
try:
|
||||||
|
validate(model_name, face_mode, face_name, strength, quality)
|
||||||
|
data = await image.read()
|
||||||
|
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if img is None:
|
||||||
|
raise EnhanceError('bad image', 400)
|
||||||
|
if img.shape[0] * img.shape[1] > MAX_PIXELS:
|
||||||
|
ratio = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5
|
||||||
|
img = cv2.resize(img, (int(img.shape[1] * ratio), int(img.shape[0] * ratio)),
|
||||||
|
interpolation=cv2.INTER_AREA)
|
||||||
|
outscale = min(max(int(scale), 2), 4)
|
||||||
|
fmt = output_format(image.filename)
|
||||||
|
warnings = []
|
||||||
|
if face_mode != 'off':
|
||||||
|
if min(img.shape[0], img.shape[1]) < MIN_FACE_SIDE:
|
||||||
|
warnings.append('вход меньше %d×%d — лица могут не найтись'
|
||||||
|
% (MIN_FACE_SIDE, MIN_FACE_SIDE))
|
||||||
|
if face_name == 'codeformer' and not state['device'].startswith('cuda'):
|
||||||
|
warnings.append('CodeFormer на %s медленнее GFPGAN' % state['device'])
|
||||||
|
output, faces_found = await asyncio.to_thread(run_guarded, img, outscale, model_name,
|
||||||
|
face_mode, face_name, float(strength), warnings)
|
||||||
|
payload, media_type = encode_image(output, fmt, int(quality))
|
||||||
|
except EnhanceError as err:
|
||||||
|
if err.status_code == 503:
|
||||||
|
return error_response(503, err.message, headers={'Retry-After': str(RETRY_AFTER_SEC)})
|
||||||
|
return error_response(err.status_code, err.message)
|
||||||
|
if face_mode != 'off' and not faces_found:
|
||||||
|
warnings.append('лица не найдены, фон обработан апскейлом')
|
||||||
|
elapsed_ms = int((time.time() - started) * 1000)
|
||||||
|
if wants_json(request):
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'image_base64': base64.b64encode(payload).decode('ascii'),
|
||||||
|
'image_ext': fmt[0],
|
||||||
|
'model': model_name,
|
||||||
|
'face': face_mode,
|
||||||
|
'face_model': face_name if face_mode != 'off' else None,
|
||||||
|
'faces_found': faces_found,
|
||||||
|
'device': state['device'],
|
||||||
|
'elapsed_ms': elapsed_ms,
|
||||||
|
'warnings': warnings,
|
||||||
|
}
|
||||||
|
return Response(payload, media_type=media_type, headers={'X-Photo-AI-Model': model_name,
|
||||||
|
'X-Photo-AI-Face': face_mode,
|
||||||
|
'X-Photo-AI-Device': state['device'],
|
||||||
|
'X-Photo-AI-Elapsed-Ms': str(elapsed_ms)})
|
||||||
|
|||||||
@@ -3,26 +3,50 @@
|
|||||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh}
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh}
|
||||||
|
|
||||||
.layout{display:flex;min-height:100dvh}
|
.layout{display:flex;min-height:100dvh}
|
||||||
.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);padding:24px 0;position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column}
|
.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column;z-index:50;box-shadow:1px 0 0 rgba(0,0,0,.02);user-select:none}
|
||||||
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-width:0}
|
.sidebar .logo{height:58px;padding:0 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-width:0;flex-shrink:0;font-size:1.02rem;font-weight:700;letter-spacing:-.01em;background:var(--card)}
|
||||||
.sidebar .logo-ic{height:28px;width:auto;min-width:28px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0}
|
.sidebar .logo-ic{height:28px;width:auto;min-width:28px;border-radius:6px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0}
|
||||||
.sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain}
|
.sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain}
|
||||||
.sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
.sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
||||||
.user-box{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:2px}
|
.user-box{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0;background:var(--card)}
|
||||||
.user-box .user-name{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.user-box .user-avatar{width:32px;height:32px;border-radius:8px;background:rgba(37,99,235,.08);color:var(--accent);display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||||
.user-box .user-role{color:var(--muted);font-size:.72rem}
|
.user-box .user-avatar svg{width:16px;height:16px}
|
||||||
.sidebar nav{flex:1;padding:12px 0}
|
.user-box .user-meta{display:flex;flex-direction:column;min-width:0;flex:1}
|
||||||
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:10px 20px;color:var(--muted);text-decoration:none;font-size:.9rem;transition:all .15s;border-left:3px solid transparent}
|
.user-box .user-name{font-weight:600;font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);line-height:1.25}
|
||||||
|
.user-box .user-role{color:var(--muted);font-size:.7rem;font-weight:500;line-height:1.2}
|
||||||
|
|
||||||
|
.nav-scroll-wrap{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;padding:8px 0;scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||||
|
.nav-scroll-wrap::-webkit-scrollbar{width:5px}
|
||||||
|
.nav-scroll-wrap::-webkit-scrollbar-track{background:transparent}
|
||||||
|
.nav-scroll-wrap::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12);border-radius:10px}
|
||||||
|
.nav-scroll-wrap::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.22)}
|
||||||
|
|
||||||
|
.sidebar nav{display:flex;flex-direction:column;gap:10px;padding:0 8px}
|
||||||
|
.nav-group{display:flex;flex-direction:column;gap:2px}
|
||||||
|
.nav-group-title{font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:6px 10px 4px;user-select:none;opacity:.8}
|
||||||
|
.nav-items{display:flex;flex-direction:column;gap:1px}
|
||||||
|
|
||||||
|
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;color:var(--muted);text-decoration:none;font-size:.84rem;font-weight:500;line-height:1.3;transition:all .14s ease;position:relative}
|
||||||
.sidebar nav a:hover{color:var(--text);background:var(--bg)}
|
.sidebar nav a:hover{color:var(--text);background:var(--bg)}
|
||||||
.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.06);border-left-color:var(--accent);font-weight:500}
|
.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.08);font-weight:600}
|
||||||
.sidebar nav a i,.sidebar .bottom a i{width:16px;height:16px;flex-shrink:0}
|
.sidebar nav a.active::before{content:'';position:absolute;left:0;top:6px;bottom:6px;width:3px;border-radius:0 3px 3px 0;background:var(--accent)}
|
||||||
|
.sidebar nav a i,.sidebar .bottom a i{width:16px;height:16px;flex-shrink:0;transition:transform .14s ease}
|
||||||
|
.sidebar nav a:hover i{transform:scale(1.06)}
|
||||||
|
.sidebar nav a .nav-text{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
|
||||||
|
.sidebar-badge{min-width:18px;height:18px;padding:0 6px;border-radius:999px;background:#ef4444;color:#fff;font-size:.65rem;font-weight:700;line-height:18px;text-align:center;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-left:auto;box-shadow:0 1px 3px rgba(239,68,68,.35)}
|
||||||
|
.sidebar-badge[hidden]{display:none}
|
||||||
|
|
||||||
.sidebar nav a[data-nav=dashboard] svg{color:#6366f1}
|
.sidebar nav a[data-nav=dashboard] svg{color:#6366f1}
|
||||||
.sidebar nav a[data-nav=journal] svg{color:#0ea5e9}
|
.sidebar nav a[data-nav=journal] svg{color:#0ea5e9}
|
||||||
|
.sidebar nav a[data-nav=notifications] svg{color:#f59e0b}
|
||||||
.sidebar nav a[data-nav=students] svg{color:#14b8a6}
|
.sidebar nav a[data-nav=students] svg{color:#14b8a6}
|
||||||
.sidebar nav a[data-nav=groups] svg{color:#8b5cf6}
|
.sidebar nav a[data-nav=groups] svg{color:#8b5cf6}
|
||||||
.sidebar nav a[data-nav=files] svg{color:#f59e0b}
|
.sidebar nav a[data-nav=files] svg{color:#f59e0b}
|
||||||
|
.sidebar nav a[data-nav=photos] svg{color:#0891b2}
|
||||||
.sidebar nav a[data-nav=links] svg{color:#06b6d4}
|
.sidebar nav a[data-nav=links] svg{color:#06b6d4}
|
||||||
.sidebar nav a[data-nav=trash] svg{color:#ef4444}
|
.sidebar nav a[data-nav=trash] svg{color:#ef4444}
|
||||||
|
.sidebar nav a[data-nav=modules] svg{color:#3b82f6}
|
||||||
.sidebar nav a[data-nav=branches] svg{color:#10b981}
|
.sidebar nav a[data-nav=branches] svg{color:#10b981}
|
||||||
.sidebar nav a[data-nav=users] svg{color:#ec4899}
|
.sidebar nav a[data-nav=users] svg{color:#ec4899}
|
||||||
.sidebar nav a[data-nav=worker] svg{color:#a855f7}
|
.sidebar nav a[data-nav=worker] svg{color:#a855f7}
|
||||||
@@ -30,12 +54,18 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.sidebar nav a[data-nav=bans] svg{color:#dc2626}
|
.sidebar nav a[data-nav=bans] svg{color:#dc2626}
|
||||||
.sidebar nav a[data-nav=settings] svg{color:#475569}
|
.sidebar nav a[data-nav=settings] svg{color:#475569}
|
||||||
.sidebar nav a.active svg{color:var(--accent)}
|
.sidebar nav a.active svg{color:var(--accent)}
|
||||||
.sidebar .bottom a[data-nav=home] svg{color:#2563eb}
|
|
||||||
.sidebar .bottom{padding:12px 20px;border-top:1px solid var(--border)}
|
.sidebar .bottom{padding:10px 10px 12px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:8px;flex-shrink:0;background:var(--card)}
|
||||||
.sidebar .version{color:var(--muted);font-size:.72rem;padding:0 20px 8px}
|
.sidebar .bottom-actions{display:flex;flex-direction:column;gap:1px}
|
||||||
.sidebar .bottom a{display:flex;align-items:center;gap:10px;color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 0}
|
.sidebar .bottom-actions a{display:flex;align-items:center;gap:10px;padding:6px 10px;border-radius:8px;color:var(--muted);text-decoration:none;font-size:.82rem;font-weight:500;transition:all .14s ease;position:relative}
|
||||||
.sidebar .bottom a:hover{color:var(--text)}
|
.sidebar .bottom-actions a:hover{color:var(--text);background:var(--bg)}
|
||||||
.sidebar .bottom .logout{color:#ef4444;cursor:pointer;margin-top:4px}
|
.sidebar .bottom-actions a[data-nav=home] svg{color:#2563eb}
|
||||||
|
.sidebar .bottom-actions .notify-bell svg{color:#f59e0b}
|
||||||
|
.sidebar .bottom-footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:6px;border-top:1px dashed var(--border)}
|
||||||
|
.sidebar .bottom-footer .logout-btn{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:6px;color:#ef4444;text-decoration:none;font-size:.78rem;font-weight:600;cursor:pointer;transition:background .14s ease}
|
||||||
|
.sidebar .bottom-footer .logout-btn:hover{background:rgba(239,68,68,.08)}
|
||||||
|
.sidebar .bottom-footer .logout-btn svg{width:14px;height:14px;color:#ef4444}
|
||||||
|
.sidebar .bottom-footer .version{color:var(--muted);font-size:.7rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.8}
|
||||||
|
|
||||||
.main{flex:1;margin-left:var(--sidebar);padding:32px}
|
.main{flex:1;margin-left:var(--sidebar);padding:32px}
|
||||||
.main h2{font-size:1.3rem;font-weight:600;margin-bottom:20px}
|
.main h2{font-size:1.3rem;font-weight:600;margin-bottom:20px}
|
||||||
@@ -131,6 +161,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.item .name-input:focus{border-color:var(--accent)}
|
.item .name-input:focus{border-color:var(--accent)}
|
||||||
.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px}
|
.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px}
|
||||||
.item .group-select:focus{border-color:var(--accent)}
|
.item .group-select:focus{border-color:var(--accent)}
|
||||||
|
.student-main{display:flex;align-items:center;gap:10px;flex:1;min-width:0}
|
||||||
|
.student-main .name-input{flex:1;min-width:0}
|
||||||
|
.student-avatar{width:40px;height:40px;flex:0 0 auto;border-radius:50%;overflow:hidden;border:1px solid var(--border);background:var(--bg);padding:0;cursor:zoom-in;display:flex;align-items:center;justify-content:center;transition:border-color .15s,opacity .15s}
|
||||||
|
.student-avatar img{width:100%;height:100%;object-fit:cover;display:block}
|
||||||
|
.student-avatar:hover{border-color:var(--accent);opacity:.9}
|
||||||
|
.student-avatar.empty{cursor:pointer;border-style:dashed}
|
||||||
|
.student-avatar.empty span{font-size:.75rem;font-weight:700;color:var(--muted);letter-spacing:.02em;user-select:none}
|
||||||
.item.expired{opacity:.6;border-color:rgba(239,68,68,.35);background:rgba(239,68,68,.03)}
|
.item.expired{opacity:.6;border-color:rgba(239,68,68,.35);background:rgba(239,68,68,.03)}
|
||||||
.item.expired:hover{opacity:.85}
|
.item.expired:hover{opacity:.85}
|
||||||
.badge-expired{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle;margin-left:6px}
|
.badge-expired{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle;margin-left:6px}
|
||||||
@@ -185,6 +222,15 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center}
|
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center}
|
||||||
.modal-overlay.open{display:flex}
|
.modal-overlay.open{display:flex}
|
||||||
.modal-overlay > img{max-width:90%;max-height:90dvh;border-radius:var(--radius)}
|
.modal-overlay > img{max-width:90%;max-height:90dvh;border-radius:var(--radius)}
|
||||||
|
#videoModal{background:rgba(0,0,0,.85)}
|
||||||
|
.video-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:16px;width:min(1000px,94vw);max-height:94dvh;display:flex;flex-direction:column;gap:12px}
|
||||||
|
.video-head{display:flex;align-items:center;gap:12px;font-size:.85rem;color:var(--text)}
|
||||||
|
.video-head > span:first-child{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.video-dl{background:none;border:1px solid var(--border);border-radius:6px;padding:3px 10px;color:var(--muted);text-decoration:none;font-size:.75rem}
|
||||||
|
.video-dl:hover{color:var(--accent);border-color:var(--accent)}
|
||||||
|
.video-close{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.4rem;line-height:1;padding:0 4px}
|
||||||
|
.video-close:hover{color:var(--text)}
|
||||||
|
#videoPlayer{width:100%;max-height:78dvh;background:#000;border-radius:var(--radius);display:block}
|
||||||
.edit-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;max-width:420px;width:90%;display:flex;flex-direction:column;gap:16px}
|
.edit-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;max-width:420px;width:90%;display:flex;flex-direction:column;gap:16px}
|
||||||
.edit-modal h3{font-size:1.1rem;font-weight:600}
|
.edit-modal h3{font-size:1.1rem;font-weight:600}
|
||||||
.edit-modal label{font-size:.8rem;color:var(--muted);margin-bottom:4px}
|
.edit-modal label{font-size:.8rem;color:var(--muted);margin-bottom:4px}
|
||||||
@@ -568,4 +614,93 @@ body[data-page=settings] .page-head{align-items:flex-end}
|
|||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}}
|
@media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}}
|
||||||
|
|
||||||
|
/* --- Notifications --- */
|
||||||
|
.notify-bell{position:relative}
|
||||||
|
.notify-badge{position:absolute;left:20px;top:-3px;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background:#ef4444;color:#fff;font-size:.62rem;font-weight:700;line-height:16px;text-align:center}
|
||||||
|
.notify-badge[hidden]{display:none}
|
||||||
|
.notify-panel{position:fixed;left:12px;bottom:96px;width:min(380px,calc(100vw - 24px));max-height:min(70vh,520px);display:flex;flex-direction:column;background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:0 18px 40px rgba(0,0,0,.18);z-index:90;overflow:hidden}
|
||||||
|
.notify-panel[hidden]{display:none}
|
||||||
|
.notify-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;border-bottom:1px solid var(--border);font-size:.9rem}
|
||||||
|
.notify-list{overflow-y:auto;flex:1}
|
||||||
|
.notify-empty{padding:22px 14px;color:var(--muted);font-size:.85rem;text-align:center}
|
||||||
|
.notify-item{display:flex;gap:10px;padding:11px 14px;border-bottom:1px solid var(--border);text-decoration:none;color:var(--text);transition:background .15s}
|
||||||
|
.notify-item:hover{background:var(--bg)}
|
||||||
|
.notify-item:last-child{border-bottom:none}
|
||||||
|
.notify-item.unread{background:rgba(37,99,235,.05)}
|
||||||
|
.notify-item.unread .notify-title{font-weight:600}
|
||||||
|
.notify-item.level-warning .notify-ic{background:rgba(245,158,11,.14);color:#f59e0b}
|
||||||
|
.notify-item.level-critical .notify-ic{background:rgba(239,68,68,.14);color:#ef4444}
|
||||||
|
.notify-ic{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;background:var(--bg);color:var(--muted);flex-shrink:0}
|
||||||
|
.notify-ic svg{width:15px;height:15px}
|
||||||
|
.notify-body{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||||
|
.notify-title{font-size:.84rem;line-height:1.3}
|
||||||
|
.notify-text{font-size:.76rem;color:var(--muted);line-height:1.35;overflow-wrap:anywhere}
|
||||||
|
.notify-time{font-size:.7rem;color:var(--muted)}
|
||||||
|
.notify-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 14px;border-top:1px solid var(--border);font-size:.78rem}
|
||||||
|
.notify-foot a{color:var(--accent);text-decoration:none}
|
||||||
|
.notify-foot a:hover{text-decoration:underline}
|
||||||
|
.notify-link{background:none;border:none;color:var(--accent);font-size:.78rem;cursor:pointer;padding:0}
|
||||||
|
.notify-link:hover{text-decoration:underline}
|
||||||
|
.notify-types{display:flex;flex-direction:column;gap:6px;margin-top:6px}
|
||||||
|
.notify-type-badge{font-size:.65rem;font-weight:700;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:1px 7px;margin-left:6px;white-space:nowrap}
|
||||||
|
.notify-page-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px}
|
||||||
|
.notify-page-head .grow{flex:1}
|
||||||
|
.notify-page-list{display:flex;flex-direction:column;gap:8px}
|
||||||
|
.notify-card{display:flex;gap:12px;padding:14px 16px;background:var(--card);border:1px solid var(--border);border-radius:12px}
|
||||||
|
.notify-card.unread{border-color:rgba(37,99,235,.45);background:rgba(37,99,235,.04)}
|
||||||
|
.notify-card.level-warning{border-left:3px solid #f59e0b}
|
||||||
|
.notify-card.level-critical{border-left:3px solid #ef4444}
|
||||||
|
.notify-card .notify-body{flex:1}
|
||||||
|
.notify-card .notify-title{font-size:.92rem}
|
||||||
|
.notify-card .notify-text{font-size:.82rem}
|
||||||
|
.notify-card-actions{display:flex;flex-direction:column;gap:6px;align-items:flex-end}
|
||||||
|
@media(max-width:1024px){
|
||||||
|
.notify-panel{left:12px;right:12px;width:auto;bottom:12px}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-modal{max-width:560px;width:92vw}
|
||||||
|
.lesson-modal .lesson-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||||
|
.lesson-modal .hint{font-size:.78rem;color:var(--muted);text-align:right}
|
||||||
|
.lesson-modal textarea{min-height:130px}
|
||||||
|
.lesson-modal .lesson-ai-toggle{background:var(--bg);border:1px solid var(--border);border-radius:10px}
|
||||||
|
.lesson-ai-status{font-size:.78rem;color:var(--muted);line-height:1.45;margin-top:-4px}
|
||||||
|
.lesson-ai-status.err{color:#ef4444}
|
||||||
|
.lesson-dup{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 12px;border-radius:10px;border:1px solid var(--border);background:var(--bg);color:var(--muted);font-size:.8rem;line-height:1.45}
|
||||||
|
.lesson-dup[hidden]{display:none}
|
||||||
|
.lesson-dup .btn-link{padding:5px 10px;font-size:.75rem;flex-shrink:0}
|
||||||
|
.lesson-versions{display:flex;flex-direction:column;gap:8px;max-height:56vh;overflow-y:auto;margin:4px 0}
|
||||||
|
.lesson-version{border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;background:var(--bg)}
|
||||||
|
.lesson-version.current{border-color:var(--accent);background:rgba(37,99,235,.06)}
|
||||||
|
.lesson-version-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||||
|
.lesson-version-label{font-size:.78rem;font-weight:600}
|
||||||
|
.lesson-version-time{font-size:.74rem;color:var(--muted);margin-left:auto}
|
||||||
|
.lesson-version-text{font-size:.82rem;line-height:1.5;white-space:pre-wrap;word-break:break-word;margin-top:6px}
|
||||||
|
.lesson-version-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px}
|
||||||
|
.lesson-version-author{font-size:.74rem;color:var(--muted)}
|
||||||
|
.lesson-version-foot .btn-link{margin-left:auto;padding:4px 10px;font-size:.75rem}
|
||||||
|
.lesson-item .ai-badge{margin-left:0}
|
||||||
|
@media(max-width:560px){
|
||||||
|
.lesson-modal .lesson-row{grid-template-columns:1fr}
|
||||||
|
}
|
||||||
|
.lesson-item{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;display:flex;flex-direction:column;gap:8px}
|
||||||
|
.lesson-item-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||||
|
.lesson-group{font-weight:600;font-size:.95rem}
|
||||||
|
.lesson-when{color:var(--muted);font-size:.78rem;display:inline-flex;align-items:center;gap:5px}
|
||||||
|
.lesson-when i{width:13px;height:13px}
|
||||||
|
.lesson-badge{font-size:.7rem;font-weight:700;padding:2px 9px;border-radius:999px;background:rgba(37,99,235,.1);color:var(--accent)}
|
||||||
|
.lesson-badge.today{background:rgba(16,185,129,.14);color:#059669}
|
||||||
|
.lesson-text{font-size:.88rem;color:var(--text);white-space:pre-wrap;word-break:break-word;line-height:1.55}
|
||||||
|
.lesson-author{font-size:.75rem;color:var(--muted)}
|
||||||
|
.lesson-actions{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto}
|
||||||
|
.lesson-item .btn-link{padding:6px 12px;font-size:.78rem}
|
||||||
|
.lesson-item .btn-link.danger{color:#ef4444;border-color:rgba(239,68,68,.35)}
|
||||||
|
.lesson-item .btn-link.danger:hover{border-color:#ef4444}
|
||||||
|
.lesson-list{display:flex;flex-direction:column;gap:10px}
|
||||||
|
.lesson-dash-item{display:flex;flex-direction:column;gap:3px;padding:8px 6px;border-radius:10px;transition:background .15s;cursor:pointer}
|
||||||
|
.lesson-dash-item:hover{background:var(--bg)}
|
||||||
|
.lesson-dash-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||||
|
.lesson-dash-group{font-weight:600;font-size:.86rem}
|
||||||
|
.lesson-dash-when{color:var(--muted);font-size:.74rem;margin-left:auto;white-space:nowrap}
|
||||||
|
.lesson-dash-text{font-size:.8rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.45}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ async function checkAuth() {
|
|||||||
const res = await fetch(`${API}/api/auth/me`, { headers: hdr() });
|
const res = await fetch(`${API}/api/auth/me`, { headers: hdr() });
|
||||||
if (!res.ok) { cleanupSession(); return false; }
|
if (!res.ok) { cleanupSession(); return false; }
|
||||||
currentUser = await res.json();
|
currentUser = await res.json();
|
||||||
|
await initDateTime();
|
||||||
return true;
|
return true;
|
||||||
} catch { cleanupSession(); return false; }
|
} catch { cleanupSession(); return false; }
|
||||||
}
|
}
|
||||||
@@ -49,8 +50,11 @@ function buildSidebar(active) {
|
|||||||
const base = [
|
const base = [
|
||||||
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
|
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
|
||||||
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
|
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
|
||||||
|
{ page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' },
|
||||||
|
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
|
||||||
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
|
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
|
||||||
{ page: 'groups', label: 'Группы', icon: 'users' },
|
{ page: 'groups', label: 'Группы', icon: 'users' },
|
||||||
|
{ page: 'photos', label: 'Фото', icon: 'Image' },
|
||||||
{ page: 'files', label: 'Файлы', icon: 'folder' },
|
{ page: 'files', label: 'Файлы', icon: 'folder' },
|
||||||
{ page: 'links', label: 'Ссылки', icon: 'link' },
|
{ page: 'links', label: 'Ссылки', icon: 'link' },
|
||||||
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
|
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
|
||||||
@@ -64,29 +68,70 @@ function buildSidebar(active) {
|
|||||||
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
|
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
|
||||||
{ page: 'settings', label: 'Настройки', icon: 'settings' },
|
{ page: 'settings', label: 'Настройки', icon: 'settings' },
|
||||||
];
|
];
|
||||||
const nav = [...base, ...(isAdmin() ? adminNav : [])];
|
|
||||||
const userName = currentUser?.name || currentUser?.username || '';
|
const userName = currentUser?.name || currentUser?.username || '';
|
||||||
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
|
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
|
||||||
|
const renderItem = n => {
|
||||||
|
const isAct = n.page === active;
|
||||||
|
const badgeHtml = n.badge ? `<span class="sidebar-badge" id="sidebarNotifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>` : '';
|
||||||
|
return `<a href="${n.page}.html" data-nav="${n.page}" class="${isAct ? 'active' : ''}" title="${n.label}">
|
||||||
|
<i data-lucide="${n.icon}"></i>
|
||||||
|
<span class="nav-text">${n.label}</span>
|
||||||
|
${badgeHtml}
|
||||||
|
</a>`;
|
||||||
|
};
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<span class="logo-ic" id="sidebarLogo"></span>
|
<span class="logo-ic" id="sidebarLogo"></span>
|
||||||
<span id="sidebarName">Админ-панель</span>
|
<span id="sidebarName">Админ-панель</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-box">
|
<div class="user-box">
|
||||||
<div class="user-name">${esc(userName)}</div>
|
<div class="user-avatar"><i data-lucide="${isAdmin() ? 'shield-check' : 'user'}"></i></div>
|
||||||
<div class="user-role">${roleLabel}</div>
|
<div class="user-meta">
|
||||||
|
<div class="user-name" title="${esc(userName)}">${esc(userName)}</div>
|
||||||
|
<div class="user-role">${roleLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-scroll-wrap">
|
||||||
|
<nav>
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group-title">Основное</div>
|
||||||
|
<div class="nav-items">
|
||||||
|
${base.map(renderItem).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${isAdmin() ? `
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group-title">Администрирование</div>
|
||||||
|
<div class="nav-items">
|
||||||
|
${adminNav.map(renderItem).join('')}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
|
||||||
${nav.map(n => `<a href="${n.page}.html" data-nav="${n.page}" class="${n.page === active ? 'active' : ''}"><i data-lucide="${n.icon}"></i>${n.label}</a>`).join('')}
|
|
||||||
</nav>
|
|
||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
<div class="version" id="sidebarVersion"></div>
|
<div class="bottom-actions">
|
||||||
<a href="/" data-nav="home"><i data-lucide="home"></i>На главную</a>
|
<a href="#" class="notify-bell" id="notifyBell" title="Быстрые уведомления">
|
||||||
<a class="logout" href="#" data-action="logout"><i data-lucide="log-out"></i>Выйти</a>
|
<i data-lucide="bell"></i>
|
||||||
|
<span>Уведомления</span>
|
||||||
|
<span class="notify-badge" id="notifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/" data-nav="home" title="Перейти на главную страницу">
|
||||||
|
<i data-lucide="home"></i>
|
||||||
|
<span>На главную</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="bottom-footer">
|
||||||
|
<a class="logout-btn" href="#" data-action="logout" title="Выйти из аккаунта">
|
||||||
|
<i data-lucide="log-out"></i>
|
||||||
|
<span>Выйти</span>
|
||||||
|
</a>
|
||||||
|
<span class="version" id="sidebarVersion"></span>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
renderIcons();
|
renderIcons();
|
||||||
loadVersion();
|
loadVersion();
|
||||||
loadBrand();
|
loadBrand();
|
||||||
|
initNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBrand() {
|
async function loadBrand() {
|
||||||
@@ -122,6 +167,231 @@ async function loadVersion() {
|
|||||||
} catch { el.textContent = ''; }
|
} catch { el.textContent = ''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Notifications ---
|
||||||
|
const NOTIFY_ICONS = {
|
||||||
|
'entry.new': 'book-open',
|
||||||
|
'entry.ai.corrected': 'sparkles',
|
||||||
|
'entry.ai.error': 'bot',
|
||||||
|
'photo.job.done': 'image',
|
||||||
|
'photo.job.error': 'image-off',
|
||||||
|
'ip.ban': 'shield-off',
|
||||||
|
'backup.restore': 'database',
|
||||||
|
'backup.create': 'download',
|
||||||
|
'system.test': 'send',
|
||||||
|
'lesson.report': 'notebook-pen',
|
||||||
|
'lesson.ai.formatted': 'file-check',
|
||||||
|
};
|
||||||
|
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' };
|
||||||
|
|
||||||
|
let notifyList = [];
|
||||||
|
let notifyUnread = 0;
|
||||||
|
let notifyStream = null;
|
||||||
|
let notifyReconnectTimer = null;
|
||||||
|
let desktopNotify = localStorage.getItem('notifyDesktop') === '1';
|
||||||
|
|
||||||
|
function notifyIcon(type) { return NOTIFY_ICONS[type] || 'bell'; }
|
||||||
|
|
||||||
|
function attrEsc(s) { return esc(String(s === null || s === undefined ? '' : s).replace(/"/g, '"')); }
|
||||||
|
|
||||||
|
function notifyTime(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
if (diff < 60000) return 'только что';
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`;
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)} ч назад`;
|
||||||
|
return fmtDateTime(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNotifyUnread(n) {
|
||||||
|
notifyUnread = Number(n) || 0;
|
||||||
|
const badge = document.getElementById('notifyBadge');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread);
|
||||||
|
badge.hidden = notifyUnread <= 0;
|
||||||
|
}
|
||||||
|
const navBadge = document.getElementById('sidebarNotifyBadge');
|
||||||
|
if (navBadge) {
|
||||||
|
navBadge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread);
|
||||||
|
navBadge.hidden = notifyUnread <= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyItemHtml(n, expanded) {
|
||||||
|
const level = NOTIFY_LEVELS[n.level] ? ` level-${n.level}` : '';
|
||||||
|
return `<a href="#" class="notify-item${n.read ? '' : ' unread'}${level}" data-notify-id="${n.id}" data-notify-link="${attrEsc(n.link || '')}">
|
||||||
|
<span class="notify-ic"><i data-lucide="${notifyIcon(n.type)}"></i></span>
|
||||||
|
<span class="notify-body">
|
||||||
|
<span class="notify-title">${esc(n.title)}</span>
|
||||||
|
${n.body ? `<span class="notify-text">${esc(n.body)}</span>` : ''}
|
||||||
|
<span class="notify-time">${esc(notifyTime(n.created_at))}</span>
|
||||||
|
</span>
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyPanelEl() {
|
||||||
|
let panel = document.getElementById('notifyPanel');
|
||||||
|
if (panel) return panel;
|
||||||
|
panel = document.createElement('div');
|
||||||
|
panel.className = 'notify-panel';
|
||||||
|
panel.id = 'notifyPanel';
|
||||||
|
panel.hidden = true;
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotifyPanel() {
|
||||||
|
const panel = notifyPanelEl();
|
||||||
|
if (!panel) return;
|
||||||
|
const items = notifyList.slice(0, 12);
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="notify-head">
|
||||||
|
<b>Уведомления</b>
|
||||||
|
<button type="button" class="notify-link" id="notifyReadAll">Прочитать все</button>
|
||||||
|
</div>
|
||||||
|
<div class="notify-list">
|
||||||
|
${items.length ? items.map(n => notifyItemHtml(n, false)).join('') : '<div class="notify-empty">Пока уведомлений нет</div>'}
|
||||||
|
</div>
|
||||||
|
<div class="notify-foot">
|
||||||
|
<a href="notifications.html">Все уведомления${notifyUnread ? ` (${notifyUnread})` : ''}</a>
|
||||||
|
<button type="button" class="notify-link" id="notifyDesktopToggle">${desktopNotify ? 'В браузере: вкл' : 'В браузере: выкл'}</button>
|
||||||
|
</div>`;
|
||||||
|
renderIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifications() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/notifications?limit=20`, { headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
notifyList = data.items || [];
|
||||||
|
setNotifyUnread(data.unread);
|
||||||
|
renderNotifyPanel();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNotifyPanel(force) {
|
||||||
|
const panel = notifyPanelEl();
|
||||||
|
if (!panel) return;
|
||||||
|
const open = force === undefined ? panel.hidden : !!force;
|
||||||
|
panel.hidden = !open;
|
||||||
|
if (open) { renderNotifyPanel(); loadNotifications(); }
|
||||||
|
}
|
||||||
|
async function markNotifyRead(id) {
|
||||||
|
const item = notifyList.find(n => n.id === id);
|
||||||
|
if (item && !item.read) {
|
||||||
|
item.read = true;
|
||||||
|
setNotifyUnread(Math.max(0, notifyUnread - 1));
|
||||||
|
renderNotifyPanel();
|
||||||
|
}
|
||||||
|
try { await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllNotifyRead() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
notifyList.forEach(n => { n.read = true; });
|
||||||
|
setNotifyUnread(0);
|
||||||
|
renderNotifyPanel();
|
||||||
|
if (typeof onNotifyListChanged === 'function') onNotifyListChanged();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDesktopNotification(n) {
|
||||||
|
if (!desktopNotify) return;
|
||||||
|
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
|
||||||
|
try {
|
||||||
|
const note = new Notification(n.title, { body: n.body || '', tag: 'whatido-' + n.id });
|
||||||
|
note.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
if (n.link) location.href = n.link;
|
||||||
|
note.close();
|
||||||
|
};
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDesktopNotifications() {
|
||||||
|
if (desktopNotify) {
|
||||||
|
desktopNotify = false;
|
||||||
|
localStorage.setItem('notifyDesktop', '0');
|
||||||
|
renderNotifyPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof Notification === 'undefined') { showToast('Браузер не поддерживает уведомления'); return; }
|
||||||
|
let perm = Notification.permission;
|
||||||
|
if (perm === 'default') {
|
||||||
|
try { perm = await Notification.requestPermission(); } catch {}
|
||||||
|
}
|
||||||
|
if (perm !== 'granted') { showToast('Разрешение на уведомления не выдано'); return; }
|
||||||
|
desktopNotify = true;
|
||||||
|
localStorage.setItem('notifyDesktop', '1');
|
||||||
|
renderNotifyPanel();
|
||||||
|
showToast('Уведомления в браузере включены');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIncomingNotification(n) {
|
||||||
|
notifyList.unshift(n);
|
||||||
|
if (notifyList.length > 40) notifyList.length = 40;
|
||||||
|
setNotifyUnread(notifyUnread + 1);
|
||||||
|
renderNotifyPanel();
|
||||||
|
if (n.level === 'warning' || n.level === 'critical') showToast(n.title);
|
||||||
|
showDesktopNotification(n);
|
||||||
|
if (typeof onNotificationArrived === 'function') onNotificationArrived(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectNotifyStream() {
|
||||||
|
if (notifyStream) { notifyStream.close(); notifyStream = null; }
|
||||||
|
const connect = () => {
|
||||||
|
const t = sessionStorage.getItem('authToken');
|
||||||
|
if (!t) return;
|
||||||
|
notifyStream = new EventSource(`${API}/api/notifications/stream?token=${encodeURIComponent(t)}`);
|
||||||
|
notifyStream.addEventListener('ready', ev => {
|
||||||
|
try { setNotifyUnread(JSON.parse(ev.data).unread); } catch {}
|
||||||
|
});
|
||||||
|
notifyStream.addEventListener('notification', ev => {
|
||||||
|
let n = null;
|
||||||
|
try { n = JSON.parse(ev.data); } catch { return; }
|
||||||
|
if (!n || !n.id) return;
|
||||||
|
handleIncomingNotification(n);
|
||||||
|
});
|
||||||
|
notifyStream.onerror = () => {
|
||||||
|
if (notifyStream) { notifyStream.close(); notifyStream = null; }
|
||||||
|
clearTimeout(notifyReconnectTimer);
|
||||||
|
notifyReconnectTimer = setTimeout(connect, 5000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
connect();
|
||||||
|
window.addEventListener('beforeunload', () => { if (notifyStream) notifyStream.close(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function initNotifications() {
|
||||||
|
const bell = document.getElementById('notifyBell');
|
||||||
|
const panel = notifyPanelEl();
|
||||||
|
if (!bell || !panel) return;
|
||||||
|
bell.addEventListener('click', e => { e.preventDefault(); toggleNotifyPanel(); });
|
||||||
|
panel.addEventListener('click', e => {
|
||||||
|
if (e.target.closest('#notifyReadAll')) { markAllNotifyRead(); return; }
|
||||||
|
if (e.target.closest('#notifyDesktopToggle')) { toggleDesktopNotifications(); return; }
|
||||||
|
const item = e.target.closest('[data-notify-id]');
|
||||||
|
if (!item) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const id = parseInt(item.dataset.notifyId, 10);
|
||||||
|
const link = item.dataset.notifyLink;
|
||||||
|
toggleNotifyPanel(false);
|
||||||
|
markNotifyRead(id);
|
||||||
|
if (link) location.href = link;
|
||||||
|
});
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (panel.hidden) return;
|
||||||
|
if (e.target.closest('#notifyPanel') || e.target.closest('#notifyBell')) return;
|
||||||
|
toggleNotifyPanel(false);
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) toggleNotifyPanel(false); });
|
||||||
|
loadNotifications();
|
||||||
|
connectNotifyStream();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Common UI ---
|
// --- Common UI ---
|
||||||
function renderIcons() {
|
function renderIcons() {
|
||||||
if (window.lucide && typeof window.lucide.createIcons === 'function') {
|
if (window.lucide && typeof window.lucide.createIcons === 'function') {
|
||||||
@@ -217,6 +487,405 @@ function copyText(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lessonModalGroups = null;
|
||||||
|
let lessonModalGroupsPromise = null;
|
||||||
|
|
||||||
|
function lessonTodayIso() {
|
||||||
|
return todayIso();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lessonNowTime() {
|
||||||
|
return nowHm();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLessonGroups() {
|
||||||
|
if (lessonModalGroups) return lessonModalGroups;
|
||||||
|
if (!lessonModalGroupsPromise) {
|
||||||
|
lessonModalGroupsPromise = fetch(`${API}/api/groups`, { headers: hdr() })
|
||||||
|
.then(res => (res.ok ? res.json() : []))
|
||||||
|
.then(g => { lessonModalGroups = Array.isArray(g) ? g.filter(x => !x.deleted_at) : []; return lessonModalGroups; })
|
||||||
|
.catch(() => { lessonModalGroups = []; return lessonModalGroups; });
|
||||||
|
}
|
||||||
|
return lessonModalGroupsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLessonModal() {
|
||||||
|
let modal = document.getElementById('lessonModal');
|
||||||
|
if (modal) return modal;
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.className = 'modal-overlay';
|
||||||
|
modal.id = 'lessonModal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="edit-modal lesson-modal">
|
||||||
|
<h3 id="lessonModalTitle">Что прошли на занятии</h3>
|
||||||
|
<form id="lessonForm" class="settings-stack" style="gap:12px" autocomplete="off">
|
||||||
|
<input type="hidden" id="lessonId">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Группа <span style="color:#ef4444">*</span></label>
|
||||||
|
<select id="lessonGroup" class="settings-input" required></select>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-row">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Дата занятия <span style="color:#ef4444">*</span></label>
|
||||||
|
<input type="date" id="lessonDate" class="settings-input" required>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Время</label>
|
||||||
|
<input type="time" id="lessonTime" class="settings-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Что прошли на занятии <span style="color:#ef4444">*</span></label>
|
||||||
|
<textarea id="lessonText" class="settings-input" rows="6" maxlength="5000" required
|
||||||
|
placeholder="Например: разобрали циклы for и while, решали задачи на списки, домашнее задание — функции"></textarea>
|
||||||
|
<span class="hint" id="lessonHint">0 / 5000</span>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-dup" id="lessonDup" hidden>
|
||||||
|
<span>За эту группу и дату отчёт уже есть — создать второй нельзя.</span>
|
||||||
|
<button type="button" class="btn-link" id="lessonDupGo">Перейти к существующему отчёту</button>
|
||||||
|
</div>
|
||||||
|
<label class="toggle lesson-ai-toggle" id="lessonAiRow">
|
||||||
|
<input type="checkbox" id="lessonAiCheck" checked>
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Проверить по шаблону делового сообщения</b><small>ИИ сверит текст с шаблоном из настроек и, если не совпадёт, оформит его как деловое сообщение</small></span>
|
||||||
|
</label>
|
||||||
|
<div class="lesson-ai-status" id="lessonAiStatus" hidden></div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="lessonVersionsBtn" hidden>История версий</button>
|
||||||
|
<button type="button" class="btn-primary ghost" id="lessonCancel">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary" id="lessonSubmit">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
const groupSel = modal.querySelector('#lessonGroup');
|
||||||
|
const textEl = modal.querySelector('#lessonText');
|
||||||
|
const hintEl = modal.querySelector('#lessonHint');
|
||||||
|
const submitBtn = modal.querySelector('#lessonSubmit');
|
||||||
|
|
||||||
|
textEl.addEventListener('input', () => { hintEl.textContent = `${textEl.value.length} / 5000`; });
|
||||||
|
modal.addEventListener('click', e => { if (e.target === modal) closeLessonModal(); });
|
||||||
|
modal.querySelector('#lessonCancel').addEventListener('click', closeLessonModal);
|
||||||
|
modal.querySelector('#lessonVersionsBtn').addEventListener('click', () => {
|
||||||
|
const id = modal.querySelector('#lessonId').value;
|
||||||
|
if (id) openLessonVersions(id);
|
||||||
|
});
|
||||||
|
groupSel.addEventListener('change', () => prefetchLessonForGroupDate());
|
||||||
|
modal.querySelector('#lessonDate').addEventListener('change', () => prefetchLessonForGroupDate());
|
||||||
|
modal.querySelector('#lessonDupGo').addEventListener('click', () => {
|
||||||
|
const id = modal.querySelector('#lessonDup').dataset.id;
|
||||||
|
if (!id) return;
|
||||||
|
closeLessonModal();
|
||||||
|
openLessonModal({ id });
|
||||||
|
});
|
||||||
|
modal.querySelector('#lessonForm').addEventListener('submit', e => submitLessonModal(e));
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitLessonModal(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const modal = document.getElementById('lessonModal');
|
||||||
|
if (!modal) return;
|
||||||
|
const groupSel = modal.querySelector('#lessonGroup');
|
||||||
|
const dateEl = modal.querySelector('#lessonDate');
|
||||||
|
const timeEl = modal.querySelector('#lessonTime');
|
||||||
|
const textEl = modal.querySelector('#lessonText');
|
||||||
|
const submitBtn = modal.querySelector('#lessonSubmit');
|
||||||
|
const aiCheckEl = modal.querySelector('#lessonAiCheck');
|
||||||
|
const groupId = groupSel.value;
|
||||||
|
const date = dateEl.value;
|
||||||
|
const text = textEl.value.trim();
|
||||||
|
if (!groupId) { showToast('Выберите группу'); groupSel.focus(); return; }
|
||||||
|
if (!date) { showToast('Укажите дату занятия'); return; }
|
||||||
|
if (!text) { showToast('Введите текст отчёта'); textEl.focus(); return; }
|
||||||
|
const id = modal.querySelector('#lessonId').value;
|
||||||
|
const aiCheck = !!aiCheckEl && aiCheckEl.checked;
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/lesson-reports${id ? '/' + id : ''}`, {
|
||||||
|
method: id ? 'PUT' : 'POST',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify({ group_id: Number(groupId), lesson_date: date, lesson_time: timeEl.value || null, text, ai_check: aiCheck }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 409 && data.id) {
|
||||||
|
showLessonDuplicate(modal, data.id);
|
||||||
|
showToast('За эту группу и дату отчёт уже есть — новый создать нельзя');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast(data.error || 'Не удалось сохранить отчёт');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeLessonModal();
|
||||||
|
if (data.ai_status === 'pending' || data.ai_status === 'processing') {
|
||||||
|
showToast('Отчёт сохранён — ИИ проверяет текст по шаблону');
|
||||||
|
} else if (data.ai_status === 'error') {
|
||||||
|
showToast(`Отчёт сохранён, проверка по шаблону не удалась: ${data.ai_error || 'ошибка ИИ'}`);
|
||||||
|
} else {
|
||||||
|
showToast(id ? 'Отчёт о занятии обновлён' : 'Отчёт о занятии сохранён');
|
||||||
|
}
|
||||||
|
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
|
||||||
|
} catch {
|
||||||
|
showToast('Ошибка сети');
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lessonDupSeq = 0;
|
||||||
|
|
||||||
|
async function prefetchLessonForGroupDate() {
|
||||||
|
const modal = document.getElementById('lessonModal');
|
||||||
|
if (!modal || !modal.classList.contains('open')) return;
|
||||||
|
if (modal.querySelector('#lessonId').value) return;
|
||||||
|
const groupId = modal.querySelector('#lessonGroup').value;
|
||||||
|
const date = modal.querySelector('#lessonDate').value;
|
||||||
|
if (!groupId || !date) { showLessonDuplicate(modal, null); return; }
|
||||||
|
const seq = ++lessonDupSeq;
|
||||||
|
try {
|
||||||
|
const q = `group_id=${encodeURIComponent(groupId)}&date_from=${encodeURIComponent(date)}&date_to=${encodeURIComponent(date)}&limit=1`;
|
||||||
|
const res = await fetch(`${API}/api/lesson-reports?${q}`, { headers: hdr() });
|
||||||
|
if (seq !== lessonDupSeq) return;
|
||||||
|
if (!res.ok) { showLessonDuplicate(modal, null); return; }
|
||||||
|
const data = await res.json();
|
||||||
|
if (seq !== lessonDupSeq) return;
|
||||||
|
const item = (data.items || [])[0];
|
||||||
|
showLessonDuplicate(modal, item ? item.id : null);
|
||||||
|
} catch {
|
||||||
|
if (seq === lessonDupSeq) showLessonDuplicate(modal, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLessonDuplicate(modal, existingId) {
|
||||||
|
const row = modal.querySelector('#lessonDup');
|
||||||
|
if (!row) return;
|
||||||
|
row.dataset.id = existingId ? String(existingId) : '';
|
||||||
|
row.hidden = !existingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillLessonModalFromReport(modal, r) {
|
||||||
|
modal.querySelector('#lessonId').value = r.id;
|
||||||
|
if (r.lesson_time && !modal.querySelector('#lessonTime').value) {
|
||||||
|
modal.querySelector('#lessonTime').value = String(r.lesson_time).slice(0, 5);
|
||||||
|
}
|
||||||
|
if (!modal.querySelector('#lessonText').value) {
|
||||||
|
const textEl = modal.querySelector('#lessonText');
|
||||||
|
textEl.value = r.text || '';
|
||||||
|
textEl.dispatchEvent(new Event('input'));
|
||||||
|
}
|
||||||
|
const aiCheck = modal.querySelector('#lessonAiCheck');
|
||||||
|
if (aiCheck) aiCheck.checked = false;
|
||||||
|
updateLessonAiRow(modal, r);
|
||||||
|
modal.querySelector('#lessonModalTitle').textContent = 'Редактирование отчёта';
|
||||||
|
modal.querySelector('#lessonSubmit').textContent = 'Обновить';
|
||||||
|
}
|
||||||
|
|
||||||
|
function lessonAiStatusText(r) {
|
||||||
|
if (!r || !r.ai_status || r.ai_status === 'none') return '';
|
||||||
|
if (r.ai_status === 'pending') return 'ИИ проверяет текст по шаблону…';
|
||||||
|
if (r.ai_status === 'processing') return 'ИИ оформляет текст по шаблону…';
|
||||||
|
if (r.ai_status === 'done' && r.text_ai) return 'ИИ оформил текст по шаблону';
|
||||||
|
if (r.ai_status === 'skipped') return 'Текст уже соответствует шаблону';
|
||||||
|
if (r.ai_status === 'reverted') return 'Возвращена исходная версия текста';
|
||||||
|
if (r.ai_status === 'error') return `Проверка по шаблону не удалась: ${r.ai_error || 'ошибка ИИ'}`;
|
||||||
|
return `Статус ИИ: ${r.ai_status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLessonAiRow(modal, r) {
|
||||||
|
const row = modal.querySelector('#lessonAiRow');
|
||||||
|
const status = modal.querySelector('#lessonAiStatus');
|
||||||
|
const versionsBtn = modal.querySelector('#lessonVersionsBtn');
|
||||||
|
if (row) row.hidden = false;
|
||||||
|
const text = lessonAiStatusText(r);
|
||||||
|
if (status) {
|
||||||
|
status.textContent = text;
|
||||||
|
status.hidden = !text;
|
||||||
|
status.classList.toggle('err', !!r && r.ai_status === 'error');
|
||||||
|
}
|
||||||
|
if (versionsBtn) versionsBtn.hidden = !modal.querySelector('#lessonId').value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLessonModalFields(modal) {
|
||||||
|
lessonDupSeq++;
|
||||||
|
modal.querySelector('#lessonId').value = '';
|
||||||
|
showLessonDuplicate(modal, null);
|
||||||
|
modal.querySelector('#lessonDate').value = lessonTodayIso();
|
||||||
|
modal.querySelector('#lessonTime').value = lessonNowTime();
|
||||||
|
const textEl = modal.querySelector('#lessonText');
|
||||||
|
textEl.value = '';
|
||||||
|
textEl.dispatchEvent(new Event('input'));
|
||||||
|
const aiCheck = modal.querySelector('#lessonAiCheck');
|
||||||
|
if (aiCheck) aiCheck.checked = true;
|
||||||
|
updateLessonAiRow(modal, null);
|
||||||
|
modal.querySelector('#lessonModalTitle').textContent = 'Что прошли на занятии';
|
||||||
|
modal.querySelector('#lessonSubmit').textContent = 'Сохранить';
|
||||||
|
}
|
||||||
|
|
||||||
|
const LESSON_VERSION_LABELS = {
|
||||||
|
manual: 'Тьютор',
|
||||||
|
ai: 'ИИ по шаблону',
|
||||||
|
restore: 'Восстановлено'
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentLessonVersionId = '';
|
||||||
|
|
||||||
|
function ensureLessonVersionsModal() {
|
||||||
|
let modal = document.getElementById('lessonVersionsModal');
|
||||||
|
if (modal) return modal;
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.className = 'modal-overlay';
|
||||||
|
modal.id = 'lessonVersionsModal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="edit-modal lesson-modal">
|
||||||
|
<h3>История версий отчёта</h3>
|
||||||
|
<div class="lesson-versions" id="lessonVersionsList"><div class="empty">Загрузка…</div></div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="lessonVersionsClose">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
modal.addEventListener('click', e => { if (e.target === modal) closeLessonVersions(); });
|
||||||
|
modal.querySelector('#lessonVersionsClose').addEventListener('click', closeLessonVersions);
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLessonVersions() {
|
||||||
|
const modal = document.getElementById('lessonVersionsModal');
|
||||||
|
if (modal) modal.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function lessonVersionItemHtml(v, index) {
|
||||||
|
const label = LESSON_VERSION_LABELS[v.source] || v.source || 'Версия';
|
||||||
|
const who = v.author_name || v.author_username || '';
|
||||||
|
return `
|
||||||
|
<div class="lesson-version${index === 0 ? ' current' : ''}">
|
||||||
|
<div class="lesson-version-head">
|
||||||
|
<span class="lesson-version-label">${esc(label)}</span>
|
||||||
|
<span class="lesson-version-time">${esc(notifyTime(v.created_at))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-version-text">${esc(v.text || '')}</div>
|
||||||
|
<div class="lesson-version-foot">
|
||||||
|
${who ? `<span class="lesson-version-author">${esc(who)}</span>` : ''}
|
||||||
|
${index === 0 ? '<span class="lesson-badge">текущая</span>' : ''}
|
||||||
|
${index === 0 ? '' : `<button type="button" class="btn-link" data-restore="${v.id}" data-report="${v.lesson_report_id || currentLessonVersionId || ''}">Восстановить</button>`}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLessonVersions(id) {
|
||||||
|
currentLessonVersionId = String(id || '');
|
||||||
|
const modal = ensureLessonVersionsModal();
|
||||||
|
const list = modal.querySelector('#lessonVersionsList');
|
||||||
|
list.innerHTML = '<div class="empty">Загрузка…</div>';
|
||||||
|
modal.classList.add('open');
|
||||||
|
renderIcons();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/lesson-reports/${id}/versions`, { headers: hdr() });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) { list.innerHTML = `<div class="empty">${esc(data.error || 'Не удалось загрузить историю')}</div>`; return; }
|
||||||
|
const items = data.items || [];
|
||||||
|
if (!items.length) { list.innerHTML = '<div class="empty">Версий пока нет</div>'; return; }
|
||||||
|
list.innerHTML = items.map(lessonVersionItemHtml).join('');
|
||||||
|
renderIcons();
|
||||||
|
} catch {
|
||||||
|
list.innerHTML = '<div class="empty">Ошибка сети</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreLessonVersion(id, versionId, btn) {
|
||||||
|
if (!confirm('Восстановить эту версию отчёта?')) return;
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/lesson-reports/${id}/versions/${versionId}/restore`, { method: 'POST', headers: hdr() });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) { showToast(data.error || 'Не удалось восстановить версию'); return; }
|
||||||
|
closeLessonVersions();
|
||||||
|
showToast('Версия отчёта восстановлена');
|
||||||
|
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
|
||||||
|
} catch {
|
||||||
|
showToast('Ошибка сети');
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('[data-restore]');
|
||||||
|
if (!btn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
let id = btn.dataset.report || currentLessonVersionId;
|
||||||
|
if (!id) {
|
||||||
|
const lessonModal = document.getElementById('lessonModal');
|
||||||
|
id = (lessonModal && lessonModal.querySelector('#lessonId').value) || '';
|
||||||
|
}
|
||||||
|
if (!id) return;
|
||||||
|
restoreLessonVersion(id, btn.dataset.restore, btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openLessonModal(opts = {}) {
|
||||||
|
const modal = ensureLessonModal();
|
||||||
|
const groupSel = modal.querySelector('#lessonGroup');
|
||||||
|
const previous = groupSel.value;
|
||||||
|
const groups = await loadLessonGroups();
|
||||||
|
groupSel.innerHTML = '';
|
||||||
|
if (!groups.length) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = '';
|
||||||
|
o.textContent = 'Нет доступных групп';
|
||||||
|
groupSel.appendChild(o);
|
||||||
|
} else {
|
||||||
|
groups.forEach(g => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = g.id;
|
||||||
|
o.textContent = g.branch_name ? `${g.name} (${g.branch_name})` : g.name;
|
||||||
|
groupSel.appendChild(o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resetLessonModalFields(modal);
|
||||||
|
const wanted = opts.groupId ? String(opts.groupId) : previous;
|
||||||
|
if (wanted && groups.some(g => String(g.id) === wanted)) groupSel.value = wanted;
|
||||||
|
|
||||||
|
if (opts.text) {
|
||||||
|
const textEl = modal.querySelector('#lessonText');
|
||||||
|
textEl.value = opts.text;
|
||||||
|
textEl.dispatchEvent(new Event('input'));
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.classList.add('open');
|
||||||
|
|
||||||
|
if (opts.id) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/lesson-reports/${opts.id}`, { headers: hdr() });
|
||||||
|
if (res.ok) {
|
||||||
|
const r = await res.json();
|
||||||
|
groupSel.value = String(r.group_id);
|
||||||
|
modal.querySelector('#lessonDate').value = String(r.lesson_date).slice(0, 10);
|
||||||
|
modal.querySelector('#lessonTime').value = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
|
||||||
|
fillLessonModalFromReport(modal, r);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
} else {
|
||||||
|
await prefetchLessonForGroupDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderIcons();
|
||||||
|
setTimeout(() => modal.querySelector('#lessonText').focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLessonModal() {
|
||||||
|
const modal = document.getElementById('lessonModal');
|
||||||
|
if (modal) modal.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
const modal = document.getElementById('lessonModal');
|
||||||
|
if (modal && modal.classList.contains('open')) closeLessonModal();
|
||||||
|
});
|
||||||
|
|
||||||
function renderPager(el, page, totalPages, onPage) {
|
function renderPager(el, page, totalPages, onPage) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (totalPages <= 1) { el.innerHTML = ''; return; }
|
if (totalPages <= 1) { el.innerHTML = ''; return; }
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/audit.js"></script>
|
<script src="js/audit.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/bans.js"></script>
|
<script src="js/bans.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/branches.js"></script>
|
<script src="js/branches.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -28,6 +28,11 @@
|
|||||||
<div class="recent" id="recentList"></div>
|
<div class="recent" id="recentList"></div>
|
||||||
<div class="dash-link-row"><a href="journal.html">Все записи →</a></div>
|
<div class="dash-link-row"><a href="journal.html">Все записи →</a></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dash-card">
|
||||||
|
<h3><span class="ic"><i data-lucide="notebook-pen"></i></span> Что прошли на занятиях</h3>
|
||||||
|
<div class="recent" id="lessonList"></div>
|
||||||
|
<div class="dash-link-row"><a href="lessons.html">Все отчёты →</a></div>
|
||||||
|
</div>
|
||||||
<div class="dash-card">
|
<div class="dash-card">
|
||||||
<h3><span class="ic"><i data-lucide="camera"></i></span> Фото групп</h3>
|
<h3><span class="ic"><i data-lucide="camera"></i></span> Фото групп</h3>
|
||||||
<div class="photos-grid-dash" id="dashPhotos"></div>
|
<div class="photos-grid-dash" id="dashPhotos"></div>
|
||||||
@@ -40,7 +45,9 @@
|
|||||||
<h3><span class="ic"><i data-lucide="zap"></i></span> Быстрые действия</h3>
|
<h3><span class="ic"><i data-lucide="zap"></i></span> Быстрые действия</h3>
|
||||||
<div class="actions-grid">
|
<div class="actions-grid">
|
||||||
<a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a>
|
<a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a>
|
||||||
|
<button type="button" class="action-btn" id="lessonReportBtn" style="border:1px solid var(--border);cursor:pointer;font-family:inherit"><span class="aic"><i data-lucide="notebook-pen"></i></span>Что прошли на занятии</button>
|
||||||
<a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a>
|
<a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a>
|
||||||
|
<a class="action-btn" href="photos.html"><span class="aic"><i data-lucide="Image"></i></span>Фото</a>
|
||||||
<a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a>
|
<a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a>
|
||||||
<a class="action-btn" href="links.html"><span class="aic"><i data-lucide="link"></i></span>Ссылки</a>
|
<a class="action-btn" href="links.html"><span class="aic"><i data-lucide="link"></i></span>Ссылки</a>
|
||||||
<a class="action-btn" href="trash.html"><span class="aic"><i data-lucide="trash-2"></i></span>Корзина</a>
|
<a class="action-btn" href="trash.html"><span class="aic"><i data-lucide="trash-2"></i></span>Корзина</a>
|
||||||
@@ -67,6 +74,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/dashboard.js"></script>
|
<script src="js/dashboard.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/js/datetime.js"></script>
|
||||||
<script src="/js/error.js"></script>
|
<script src="/js/error.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/files.js"></script>
|
<script src="js/files.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -71,7 +71,72 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="groupFilesModal">
|
||||||
|
<div class="edit-modal" style="max-width:440px">
|
||||||
|
<h3 id="groupFilesTitle">Архив файлов группы</h3>
|
||||||
|
<form id="groupFilesForm" class="settings-stack" style="gap:14px">
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Группа <span style="color:#ef4444">*</span></label>
|
||||||
|
<select id="groupFilesGroup" class="settings-input" required></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Дата с</label>
|
||||||
|
<input type="date" id="groupFilesFrom" class="settings-input">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Дата по</label>
|
||||||
|
<input type="date" id="groupFilesTo" class="settings-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<div class="hint" style="font-size:.78rem;color:var(--muted);margin:0">Файлы всех учеников группы за период. По умолчанию — с самого начала и по сегодня.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="groupFilesIncludeWorks" checked>
|
||||||
|
Работы (файлы проектов)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="groupFilesIncludePhotos">
|
||||||
|
Фото записи
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label>Какие фото выгружать</label>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:8px">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
|
||||||
|
<input type="checkbox" id="groupFilesMainPhoto" checked>
|
||||||
|
Главное фото каждой записи
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
|
||||||
|
<input type="checkbox" id="groupFilesGallery">
|
||||||
|
Вся галерея записи
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
|
||||||
|
<input type="checkbox" id="groupFilesOriginals">
|
||||||
|
Оригиналы фото (до обработки)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span class="hint" style="font-size:.78rem">Главное фото — текущая версия снимка записи. Галерея — все фото записи. Оригиналы — версии до обработки, они попадают в подпапку «Оригиналы», если обработка уже применялась.</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="closeGroupFilesBtn">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary" id="downloadGroupFilesBtn">Скачать ZIP</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="vendor/jszip.min.js"></script>
|
<script src="vendor/jszip.min.js"></script>
|
||||||
<script src="js/groups.js"></script>
|
<script src="js/groups.js"></script>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ nav a:hover{color:var(--text)}
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Что сделал</label>
|
<label>Что сделал</label>
|
||||||
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
<textarea id="descInput" maxlength="300" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="text" name="website" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
|
<input type="text" name="website" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
|
||||||
@@ -184,6 +184,7 @@ nav a:hover{color:var(--text)}
|
|||||||
|
|
||||||
<div class="toast" id="toast">Готово!</div>
|
<div class="toast" id="toast">Готово!</div>
|
||||||
|
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="js/index.js"></script>
|
<script src="js/index.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -27,6 +27,8 @@
|
|||||||
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
|
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
.enhance-sliders .settings-field input[type=range] { width: 100%; }
|
.enhance-sliders .settings-field input[type=range] { width: 100%; }
|
||||||
.enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
.enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
||||||
|
.enhance-ai-mode { width: auto; min-width: 200px; padding: 9px 12px; font-size: .85rem; }
|
||||||
|
.enhance-ai-hint { font-size: .75rem; color: var(--muted); max-width: 340px; }
|
||||||
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
|
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.enhance-body { flex-direction: column; }
|
.enhance-body { flex-direction: column; }
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
<div><label>По</label><input type="date" id="dateTo"></div>
|
<div><label>По</label><input type="date" id="dateTo"></div>
|
||||||
<div><label> </label><button type="button" id="exportCsvBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
|
<div><label> </label><button type="button" id="exportCsvBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
|
||||||
<div><label> </label><button type="button" id="createLinkBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Ссылка</button></div>
|
<div><label> </label><button type="button" id="createLinkBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Ссылка</button></div>
|
||||||
|
<div><label> </label><button type="button" id="lessonReportBtn" style="background:var(--card);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 16px;font-size:.85rem;font-weight:600;cursor:pointer;white-space:nowrap">Что прошли на занятии</button></div>
|
||||||
<div><label> </label>
|
<div><label> </label>
|
||||||
<div class="view-toggle" id="viewToggle">
|
<div class="view-toggle" id="viewToggle">
|
||||||
<button data-view="list" type="button">Список</button>
|
<button data-view="list" type="button">Список</button>
|
||||||
@@ -64,6 +67,17 @@
|
|||||||
<img id="imgModalSrc">
|
<img id="imgModalSrc">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="videoModal">
|
||||||
|
<div class="video-modal">
|
||||||
|
<div class="video-head">
|
||||||
|
<span id="videoTitle"></span>
|
||||||
|
<a class="video-dl" id="videoDownload" download title="Скачать файл">Скачать</a>
|
||||||
|
<button type="button" class="video-close" id="videoClose" title="Закрыть">×</button>
|
||||||
|
</div>
|
||||||
|
<video id="videoPlayer" controls playsinline preload="metadata"></video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="editModal">
|
<div class="modal-overlay" id="editModal">
|
||||||
<div class="edit-modal" style="max-width:720px">
|
<div class="edit-modal" style="max-width:720px">
|
||||||
<h3>Редактирование записи</h3>
|
<h3>Редактирование записи</h3>
|
||||||
@@ -224,6 +238,13 @@
|
|||||||
<div class="card-foot enhance-foot">
|
<div class="card-foot enhance-foot">
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button>
|
<button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button>
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
|
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
|
||||||
|
<select id="enhanceAiMode" class="settings-input enhance-ai-mode" style="display:none" title="Режим ИИ-обработки фото">
|
||||||
|
<option value="x2">Универсально (x2)</option>
|
||||||
|
<option value="x4">Быстро (x4)</option>
|
||||||
|
<option value="faces">Лица (GFPGAN)</option>
|
||||||
|
<option value="facesbg">Лица + фон</option>
|
||||||
|
</select>
|
||||||
|
<span class="enhance-ai-hint" id="enhanceAiHint" style="display:none"></span>
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
|
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
|
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
|
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
|
||||||
@@ -244,6 +265,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/journal.js"></script>
|
<script src="js/journal.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -26,10 +26,16 @@ const ACTION_LABELS = {
|
|||||||
'student.update': 'Изменён ученик',
|
'student.update': 'Изменён ученик',
|
||||||
'student.delete': 'Удалён ученик',
|
'student.delete': 'Удалён ученик',
|
||||||
'student.batch-group': 'Ученики перенесены в группу',
|
'student.batch-group': 'Ученики перенесены в группу',
|
||||||
|
'student.profile.update': 'Изменён профиль ученика',
|
||||||
|
'student.photo.create': 'Загружено фото ученика',
|
||||||
|
'student.photo.delete': 'Удалено фото ученика',
|
||||||
|
'student.photo.main': 'Главное фото ученика',
|
||||||
'module.create': 'Создан модуль',
|
'module.create': 'Создан модуль',
|
||||||
'module.update': 'Изменён модуль',
|
'module.update': 'Изменён модуль',
|
||||||
'module.delete': 'Модуль отключён',
|
'module.delete': 'Модуль отключён',
|
||||||
'module.restore': 'Модуль включён',
|
'module.restore': 'Модуль включён',
|
||||||
|
'module.photo.create': 'Загружено фото модуля',
|
||||||
|
'module.photo.delete': 'Удалено фото модуля',
|
||||||
'entry.update': 'Изменена запись',
|
'entry.update': 'Изменена запись',
|
||||||
'entry.photo.update': 'Изменено фото записи',
|
'entry.photo.update': 'Изменено фото записи',
|
||||||
'entry.photo.set_main': 'Главное фото записи',
|
'entry.photo.set_main': 'Главное фото записи',
|
||||||
@@ -42,10 +48,15 @@ const ACTION_LABELS = {
|
|||||||
'entry.photo.restore_original': 'Возвращён оригинал фото',
|
'entry.photo.restore_original': 'Возвращён оригинал фото',
|
||||||
'photo.job.preview': 'Создан предпросмотр обработки фото',
|
'photo.job.preview': 'Создан предпросмотр обработки фото',
|
||||||
'photo.job.retry': 'Повтор обработки фото',
|
'photo.job.retry': 'Повтор обработки фото',
|
||||||
|
'photo.job.soft_retry': 'ИИ-сервис недоступен, ожидание',
|
||||||
'photo.job.error': 'Ошибка обработки фото',
|
'photo.job.error': 'Ошибка обработки фото',
|
||||||
'photo-jobs.wake': 'Фото-воркер разбужен',
|
'photo-jobs.wake': 'Фото-воркер разбужен',
|
||||||
'photo-jobs.enabled': 'Переключена обработка фото',
|
'photo-jobs.enabled': 'Переключена обработка фото',
|
||||||
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
|
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
|
||||||
|
'notifications.delete': 'Удалено уведомление',
|
||||||
|
'notifications.clear': 'Очищены уведомления',
|
||||||
|
'settings.logo.upload': 'Загружен логотип системы',
|
||||||
|
'settings.logo.remove': 'Удалён логотип системы',
|
||||||
'entry.soft-delete': 'Запись в корзину',
|
'entry.soft-delete': 'Запись в корзину',
|
||||||
'entry.restore': 'Запись восстановлена',
|
'entry.restore': 'Запись восстановлена',
|
||||||
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
||||||
@@ -70,6 +81,14 @@ const ACTION_LABELS = {
|
|||||||
'ai.profile.delete': 'Удалён профиль ИИ',
|
'ai.profile.delete': 'Удалён профиль ИИ',
|
||||||
'ai.profile.activate': 'Выбран активный профиль ИИ',
|
'ai.profile.activate': 'Выбран активный профиль ИИ',
|
||||||
'export.student': 'Экспорт данных ученика',
|
'export.student': 'Экспорт данных ученика',
|
||||||
|
'export.group_files': 'Экспорт файлов группы (ZIP)',
|
||||||
|
'lesson_report.create': 'Создан отчёт о занятии',
|
||||||
|
'lesson_report.update': 'Изменён отчёт о занятии',
|
||||||
|
'lesson_report.delete': 'Удалён отчёт о занятии',
|
||||||
|
'lesson_report.ai.format': 'ИИ оформил отчёт по шаблону',
|
||||||
|
'lesson_report.ai.error': 'Ошибка проверки отчёта по шаблону',
|
||||||
|
'lesson_report.ai.revert': 'Возвращён оригинал отчёта',
|
||||||
|
'lesson_report.version.restore': 'Восстановлена версия отчёта',
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUDIT_PAGE_SIZE = 50;
|
const AUDIT_PAGE_SIZE = 50;
|
||||||
@@ -92,7 +111,8 @@ const FIELD_LABELS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function fmtTime(t) {
|
function fmtTime(t) {
|
||||||
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
|
const f = fmtFull(t);
|
||||||
|
return f || t || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTarget(t) {
|
function parseTarget(t) {
|
||||||
@@ -190,7 +210,7 @@ function renderTargetDetails(target) {
|
|||||||
<details class="audit-raw"><summary>Технические данные (JSON)</summary><pre>${esc(fullTarget(target))}</pre></details>`;
|
<details class="audit-raw"><summary>Технические данные (JSON)</summary><pre>${esc(fullTarget(target))}</pre></details>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPager(container, page, totalPages, onPageChange) {
|
function renderAuditPager(container, page, totalPages, onPageChange) {
|
||||||
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
||||||
const pages = [];
|
const pages = [];
|
||||||
const maxVisible = 5;
|
const maxVisible = 5;
|
||||||
@@ -239,7 +259,7 @@ async function loadAudit() {
|
|||||||
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
|
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
}
|
}
|
||||||
renderPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
|
renderAuditPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
function goAuditPage(p) {
|
function goAuditPage(p) {
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ function renderPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td><b>${esc(b.ip)}</b></td>
|
<td><b>${esc(b.ip)}</b></td>
|
||||||
<td><span class="reason-badge ${rcls}">${esc(reason)}</span></td>
|
<td><span class="reason-badge ${rcls}">${esc(reason)}</span></td>
|
||||||
<td>${new Date(b.created_at).toLocaleString('ru')}</td>
|
<td>${fmtFull(b.created_at)}</td>
|
||||||
<td>${new Date(b.banned_until).toLocaleString('ru')}</td>
|
<td>${fmtFull(b.banned_until)}</td>
|
||||||
<td><button class="row-btn" data-unban="${esc(b.ip)}" title="Разблокировать">✕</button></td>
|
<td><button class="row-btn" data-unban="${esc(b.ip)}" title="Разблокировать">✕</button></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
@@ -30,22 +30,22 @@ function renderStats(s, disk) {
|
|||||||
|
|
||||||
function last14days() {
|
function last14days() {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (let i = 13; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); out.push(d); }
|
const base = todayIso();
|
||||||
|
for (let i = 13; i >= 0; i--) out.push(isoAddDays(base, -i));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderActivity(activity) {
|
function renderActivity(activity) {
|
||||||
const map = {};
|
const map = {};
|
||||||
activity.forEach(a => { map[a.d] = a.n; });
|
activity.forEach(a => { map[a.d] = a.n; });
|
||||||
const days = last14days().map(d => {
|
const days = last14days().map(iso => {
|
||||||
const iso = d.toISOString().slice(0, 10);
|
return { iso, label: fmtDayMonthIso(iso), n: map[iso] || 0 };
|
||||||
return { iso, label: d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit' }), n: map[iso] || 0 };
|
|
||||||
});
|
});
|
||||||
const max = Math.max(1, ...days.map(x => x.n));
|
const max = Math.max(1, ...days.map(x => x.n));
|
||||||
const todayIso = new Date().toISOString().slice(0, 10);
|
const todayKey = todayIso();
|
||||||
document.getElementById('activityChart').innerHTML = days.map(x => `
|
document.getElementById('activityChart').innerHTML = days.map(x => `
|
||||||
<div class="bar-col" title="${x.iso} — ${x.n} ${plural(x.n, 'запись', 'записи', 'записей')}">
|
<div class="bar-col" title="${x.iso} — ${x.n} ${plural(x.n, 'запись', 'записи', 'записей')}">
|
||||||
<div class="bar ${x.iso === todayIso ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
|
<div class="bar ${x.iso === todayKey ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
|
||||||
<div class="bar-day">${x.label}</div>
|
<div class="bar-day">${x.label}</div>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ function renderRecent(rows) {
|
|||||||
: `<div class="thumb"><i data-lucide="graduation-cap"></i></div>`}
|
: `<div class="thumb"><i data-lucide="graduation-cap"></i></div>`}
|
||||||
<div class="r-info">
|
<div class="r-info">
|
||||||
<div class="r-name">${esc(e.student_name)}</div>
|
<div class="r-name">${esc(e.student_name)}</div>
|
||||||
<div class="r-meta">${esc(e.group_name)} · ${new Date(e.created_at).toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
|
<div class="r-meta">${esc(e.group_name)} · ${fmtDateTime(e.created_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="r-link" href="journal.html" title="В журнал">→</a>
|
<a class="r-link" href="journal.html" title="В журнал">→</a>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
@@ -100,6 +100,24 @@ function renderPhotos(rows) {
|
|||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderLessons(rows) {
|
||||||
|
const el = document.getElementById('lessonList');
|
||||||
|
if (!el) return;
|
||||||
|
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Отчётов пока нет</div>'; return; }
|
||||||
|
el.innerHTML = rows.map(r => {
|
||||||
|
const date = String(r.lesson_date || '').slice(0, 10).split('-').reverse().join('.');
|
||||||
|
const time = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
|
||||||
|
return `
|
||||||
|
<div class="lesson-dash-item" data-lesson="${r.id}">
|
||||||
|
<div class="lesson-dash-head">
|
||||||
|
<span class="lesson-dash-group">${esc(r.group_name)}</span>
|
||||||
|
<span class="lesson-dash-when">${esc(date + (time ? ' · ' + time : ''))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-dash-text">${esc(r.text)}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
|
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
|
||||||
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
|
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
|
||||||
@@ -110,11 +128,24 @@ async function loadDashboard() {
|
|||||||
renderActive(d.active_groups);
|
renderActive(d.active_groups);
|
||||||
renderTop(d.top_students);
|
renderTop(d.top_students);
|
||||||
renderPhotos(d.photos);
|
renderPhotos(d.photos);
|
||||||
|
renderLessons(d.recent_lessons || []);
|
||||||
renderIcons();
|
renderIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||||
|
|
||||||
|
const lessonBtn = document.getElementById('lessonReportBtn');
|
||||||
|
if (lessonBtn) lessonBtn.addEventListener('click', () => openLessonModal());
|
||||||
|
|
||||||
|
function onLessonReportSaved() {
|
||||||
|
loadDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const item = e.target.closest('[data-lesson]');
|
||||||
|
if (item && !e.target.closest('button')) openLessonModal({ id: item.dataset.lesson });
|
||||||
|
});
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
if (await checkAuth()) {
|
if (await checkAuth()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
const DateTime = { tz: '', hour12: false, ready: false, listeners: [] };
|
||||||
|
|
||||||
|
const DT_FALLBACK_TZ = 'Europe/Moscow';
|
||||||
|
const DT_CACHE = new Map();
|
||||||
|
|
||||||
|
function dtValidTz(tz) {
|
||||||
|
if (!tz || typeof tz !== 'string') return false;
|
||||||
|
try { new Intl.DateTimeFormat('ru-RU', { timeZone: tz }); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtBrowserTz() {
|
||||||
|
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || DT_FALLBACK_TZ; } catch { return DT_FALLBACK_TZ; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtFmt(opts) {
|
||||||
|
const key = DateTime.tz + '|' + (DateTime.hour12 ? '12' : '24') + '|' + JSON.stringify(opts);
|
||||||
|
let f = DT_CACHE.get(key);
|
||||||
|
if (!f) {
|
||||||
|
f = new Intl.DateTimeFormat('ru-RU', Object.assign({ timeZone: DateTime.tz, hour12: DateTime.hour12 }, opts));
|
||||||
|
DT_CACHE.set(key, f);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtDate(v) {
|
||||||
|
if (!v) return null;
|
||||||
|
const d = v instanceof Date ? v : new Date(v);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDt(v, opts) {
|
||||||
|
const d = dtDate(v);
|
||||||
|
return d ? dtFmt(opts).format(d) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtFmtParts() {
|
||||||
|
return { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtFull(v) { return fmtDt(v, dtFmtParts()); }
|
||||||
|
function fmtDateTime(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); }
|
||||||
|
function fmtDateTimeY(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); }
|
||||||
|
function fmtDateFull(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: 'numeric' }); }
|
||||||
|
function fmtDayMonth(v) { return fmtDt(v, { day: '2-digit', month: '2-digit' }); }
|
||||||
|
function fmtDateShort(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: '2-digit' }); }
|
||||||
|
function fmtDateLong(v) { return fmtDt(v, { day: 'numeric', month: 'long', year: 'numeric' }); }
|
||||||
|
function fmtTimeOnly(v) { return fmtDt(v, { hour: '2-digit', minute: '2-digit' }); }
|
||||||
|
|
||||||
|
function dtIsoMatch(v) {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v == null ? '' : v));
|
||||||
|
return m ? { y: m[1], m: m[2], d: m[3] } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtPad(n) { return String(n).padStart(2, '0'); }
|
||||||
|
|
||||||
|
function fmtDateOnlyIso(v) {
|
||||||
|
const p = dtIsoMatch(v);
|
||||||
|
return p ? `${p.d}.${p.m}.${p.y}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDayMonthIso(v) {
|
||||||
|
const p = dtIsoMatch(v);
|
||||||
|
return p ? `${p.d}.${p.m}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtShortIso(v) {
|
||||||
|
const p = dtIsoMatch(v);
|
||||||
|
return p ? `${p.d}.${p.m}.${p.y.slice(2)}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtHmStr(v) {
|
||||||
|
const m = /^(\d{1,2}):(\d{2})/.exec(String(v == null ? '' : v));
|
||||||
|
if (!m) return '';
|
||||||
|
const h = Number(m[1]);
|
||||||
|
const mm = m[2];
|
||||||
|
if (!DateTime.hour12) return `${dtPad(h)}:${mm}`;
|
||||||
|
const ap = h < 12 ? 'AM' : 'PM';
|
||||||
|
const h12 = h % 12 === 0 ? 12 : h % 12;
|
||||||
|
return `${h12}:${mm} ${ap}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateOnlyLongIso(v) {
|
||||||
|
const p = dtIsoMatch(v);
|
||||||
|
if (!p) return '';
|
||||||
|
const months = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
|
||||||
|
return `${Number(p.d)} ${months[Number(p.m) - 1] || ''} ${p.y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtZoneDate(d) {
|
||||||
|
try { return new Intl.DateTimeFormat('en-CA', { timeZone: DateTime.tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(d); } catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayIso() { return dtZoneDate(new Date()); }
|
||||||
|
|
||||||
|
function todayDow() {
|
||||||
|
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
try { return names.indexOf(new Intl.DateTimeFormat('en-US', { timeZone: DateTime.tz, weekday: 'short' }).format(new Date())); } catch { return new Date().getDay(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowHm() {
|
||||||
|
try { return new Intl.DateTimeFormat('en-GB', { timeZone: DateTime.tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date()); } catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowYear() {
|
||||||
|
try { return new Intl.DateTimeFormat('en-CA', { timeZone: DateTime.tz, year: 'numeric' }).format(new Date()); } catch { return String(new Date().getFullYear()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoAddDays(iso, n) {
|
||||||
|
const p = dtIsoMatch(iso);
|
||||||
|
if (!p) return '';
|
||||||
|
const d = new Date(Date.UTC(Number(p.y), Number(p.m) - 1, Number(p.d)));
|
||||||
|
d.setUTCDate(d.getUTCDate() + n);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtApply(s) {
|
||||||
|
if (!s || typeof s !== 'object') return;
|
||||||
|
if (dtValidTz(s.timezone)) DateTime.tz = s.timezone;
|
||||||
|
DateTime.hour12 = s.time_format === '12h';
|
||||||
|
DateTime.listeners.forEach(fn => { try { fn(DateTime); } catch {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initDateTime() {
|
||||||
|
DateTime.tz = dtBrowserTz();
|
||||||
|
DateTime.hour12 = false;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/public-settings');
|
||||||
|
if (res.ok) dtApply(await res.json());
|
||||||
|
} catch {}
|
||||||
|
DateTime.ready = true;
|
||||||
|
return DateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateTimeChange(fn) {
|
||||||
|
if (typeof fn === 'function') DateTime.listeners.push(fn);
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ function fileCardHTML(f) {
|
|||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="file-name"><a ${target}>${esc(f.name)}</a></div>
|
<div class="file-name"><a ${target}>${esc(f.name)}</a></div>
|
||||||
${meta}
|
${meta}
|
||||||
<div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
|
<div class="time">${fmtFull(f.created_at)}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-actions">${action}</div>
|
<div class="f-actions">${action}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,8 +65,9 @@ async function loadGroups() {
|
|||||||
<button class="photo-btn" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">Фото</button>
|
<button class="photo-btn" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">Фото</button>
|
||||||
<button class="btn-link" data-export-group="${g.id}" data-group-name="${esc(g.name)}" title="Экспортировать записи группы">CSV</button>
|
<button class="btn-link" data-export-group="${g.id}" data-group-name="${esc(g.name)}" title="Экспортировать записи группы">CSV</button>
|
||||||
<button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button>
|
<button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button>
|
||||||
|
<button class="btn-link" data-export-files="${g.id}" data-group-name="${esc(g.name)}" title="Скачать ZIP-архив файлов группы за период">ZIP</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
|
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${fmtHmStr(g.time_start)}–${fmtHmStr(g.time_end)}` : 'Не настроено'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('') + `</div>`;
|
}).join('') + `</div>`;
|
||||||
@@ -161,11 +162,11 @@ async function loadGroupPhotos() {
|
|||||||
|
|
||||||
function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); }
|
function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); }
|
||||||
|
|
||||||
function fmtPhotoDate(d) { return d ? d.split('-').reverse().join('.') : ''; }
|
function fmtPhotoDate(d) { return fmtDateOnlyIso(d); }
|
||||||
|
|
||||||
function photoHTML(p) {
|
function photoHTML(p) {
|
||||||
const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : '';
|
const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : '';
|
||||||
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`;
|
const uploaded = `Загружено: ${fmtDateFull(p.created_at)}`;
|
||||||
const isCover = currentCover && p.photo_path === currentCover;
|
const isCover = currentCover && p.photo_path === currentCover;
|
||||||
return `
|
return `
|
||||||
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" draggable="true">
|
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" draggable="true">
|
||||||
@@ -273,7 +274,7 @@ async function exportGroup(id, name) {
|
|||||||
e.student_name,
|
e.student_name,
|
||||||
e.group_name,
|
e.group_name,
|
||||||
e.description.replace(/\n/g, ' '),
|
e.description.replace(/\n/g, ' '),
|
||||||
new Date(e.created_at).toLocaleString('ru'),
|
fmtFull(e.created_at),
|
||||||
`${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}`
|
`${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}`
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -297,6 +298,100 @@ async function exportGroupUrls(groupId, groupName) {
|
|||||||
showToast(`Экспортировано: ${list.length}`);
|
showToast(`Экспортировано: ${list.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openGroupFilesModal(groupId, groupName) {
|
||||||
|
const sel = document.getElementById('groupFilesGroup');
|
||||||
|
sel.innerHTML = allGroups.map(g => `<option value="${g.id}" ${String(g.id) === String(groupId) ? 'selected' : ''}>${esc(g.name)}</option>`).join('');
|
||||||
|
document.getElementById('groupFilesTitle').textContent = `Архив файлов — ${groupName}`;
|
||||||
|
document.getElementById('groupFilesFrom').value = '';
|
||||||
|
document.getElementById('groupFilesTo').value = todayISOLocal();
|
||||||
|
document.getElementById('groupFilesIncludeWorks').checked = true;
|
||||||
|
document.getElementById('groupFilesIncludePhotos').checked = false;
|
||||||
|
document.getElementById('groupFilesMainPhoto').checked = true;
|
||||||
|
document.getElementById('groupFilesGallery').checked = false;
|
||||||
|
document.getElementById('groupFilesOriginals').checked = false;
|
||||||
|
onGroupFilesPhotosToggle();
|
||||||
|
document.getElementById('groupFilesModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGroupFilesModal() {
|
||||||
|
document.getElementById('groupFilesModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayISOLocal() {
|
||||||
|
return todayIso();
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_FILES_PHOTO_BOXES = ['groupFilesMainPhoto', 'groupFilesGallery', 'groupFilesOriginals'];
|
||||||
|
|
||||||
|
function onGroupFilesPhotosToggle() {
|
||||||
|
const on = document.getElementById('groupFilesIncludePhotos').checked;
|
||||||
|
GROUP_FILES_PHOTO_BOXES.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.disabled = !on;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupFilesPhotosOptions() {
|
||||||
|
const main = document.getElementById('groupFilesMainPhoto').checked;
|
||||||
|
const gallery = document.getElementById('groupFilesGallery').checked;
|
||||||
|
const originals = document.getElementById('groupFilesOriginals').checked;
|
||||||
|
return {
|
||||||
|
main,
|
||||||
|
gallery,
|
||||||
|
originals,
|
||||||
|
mode: gallery ? 'all' : 'latest',
|
||||||
|
any: main || gallery || originals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadGroupFiles() {
|
||||||
|
const groupId = document.getElementById('groupFilesGroup').value;
|
||||||
|
const dateFrom = document.getElementById('groupFilesFrom').value;
|
||||||
|
const dateTo = document.getElementById('groupFilesTo').value;
|
||||||
|
const includeWorks = document.getElementById('groupFilesIncludeWorks').checked;
|
||||||
|
const includePhotos = document.getElementById('groupFilesIncludePhotos').checked;
|
||||||
|
const photoOpts = groupFilesPhotosOptions();
|
||||||
|
if (!groupId) { alert('Выберите группу'); return; }
|
||||||
|
if (!includeWorks && !includePhotos) { alert('Выберите хотя бы одну категорию: работы или фото записи'); return; }
|
||||||
|
if (includePhotos && !photoOpts.any) { alert('Выберите, какие фото выгружать: главное фото, галерея или оригиналы'); return; }
|
||||||
|
if (dateFrom && dateTo && dateFrom > dateTo) { alert('Дата «С» позже даты «По»'); return; }
|
||||||
|
const btn = document.getElementById('downloadGroupFilesBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Формируем...';
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (dateFrom) q.set('date_from', dateFrom);
|
||||||
|
if (dateTo) q.set('date_to', dateTo);
|
||||||
|
q.set('include_files', includeWorks ? '1' : '0');
|
||||||
|
q.set('include_photos', includePhotos ? '1' : '0');
|
||||||
|
q.set('include_main', photoOpts.main ? '1' : '0');
|
||||||
|
q.set('photos_mode', photoOpts.mode);
|
||||||
|
q.set('include_originals', photoOpts.originals ? '1' : '0');
|
||||||
|
const res = await fetch(`${API}/api/groups/${groupId}/export/files?${q}`, { headers: hdr() });
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = 'Не удалось сформировать архив';
|
||||||
|
try { const e = await res.json(); msg = e.error || msg; } catch {}
|
||||||
|
alert(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
let disp = res.headers.get('Content-Disposition') || '';
|
||||||
|
let fname = 'group_files.zip';
|
||||||
|
const star = disp.match(/filename\*=UTF-8''([^;]+)/i);
|
||||||
|
const plain = disp.match(/filename="?([^";]+)"?/i);
|
||||||
|
if (star) { try { fname = decodeURIComponent(star[1]); } catch { fname = plain ? plain[1] : fname; } }
|
||||||
|
else if (plain) fname = plain[1];
|
||||||
|
downloadBlob(blob, fname);
|
||||||
|
showToast('Архив сформирован');
|
||||||
|
closeGroupFilesModal();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка при формировании архива: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Скачать ZIP';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
|
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
|
||||||
document.getElementById('addGroupBtn').addEventListener('click', addGroup);
|
document.getElementById('addGroupBtn').addEventListener('click', addGroup);
|
||||||
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
|
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
|
||||||
@@ -307,6 +402,10 @@ document.getElementById('photoForm').addEventListener('submit', e => {
|
|||||||
|
|
||||||
document.getElementById('photosModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotos(); });
|
document.getElementById('photosModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotos(); });
|
||||||
document.getElementById('photoFormModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotoForm(); });
|
document.getElementById('photoFormModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotoForm(); });
|
||||||
|
document.getElementById('groupFilesModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGroupFilesModal(); });
|
||||||
|
document.getElementById('closeGroupFilesBtn').addEventListener('click', closeGroupFilesModal);
|
||||||
|
document.getElementById('groupFilesForm').addEventListener('submit', e => { e.preventDefault(); downloadGroupFiles(); });
|
||||||
|
document.getElementById('groupFilesIncludePhotos').addEventListener('change', onGroupFilesPhotosToggle);
|
||||||
document.getElementById('openPhotoBtn').addEventListener('click', openAddPhoto);
|
document.getElementById('openPhotoBtn').addEventListener('click', openAddPhoto);
|
||||||
document.getElementById('closePhotosBtn').addEventListener('click', closePhotos);
|
document.getElementById('closePhotosBtn').addEventListener('click', closePhotos);
|
||||||
document.getElementById('closePhotoFormBtn').addEventListener('click', closePhotoForm);
|
document.getElementById('closePhotoFormBtn').addEventListener('click', closePhotoForm);
|
||||||
@@ -320,6 +419,8 @@ document.getElementById('groupList').addEventListener('click', e => {
|
|||||||
if (eg) { exportGroup(parseInt(eg.dataset.exportGroup, 10), eg.dataset.groupName); return; }
|
if (eg) { exportGroup(parseInt(eg.dataset.exportGroup, 10), eg.dataset.groupName); return; }
|
||||||
const eu = e.target.closest('[data-export-urls]');
|
const eu = e.target.closest('[data-export-urls]');
|
||||||
if (eu) { exportGroupUrls(parseInt(eu.dataset.exportUrls, 10), eu.dataset.groupName); return; }
|
if (eu) { exportGroupUrls(parseInt(eu.dataset.exportUrls, 10), eu.dataset.groupName); return; }
|
||||||
|
const ef = e.target.closest('[data-export-files]');
|
||||||
|
if (ef) { openGroupFilesModal(parseInt(ef.dataset.exportFiles, 10), ef.dataset.groupName); return; }
|
||||||
const photoBtn = e.target.closest('.photo-btn');
|
const photoBtn = e.target.closest('.photo-btn');
|
||||||
if (photoBtn) {
|
if (photoBtn) {
|
||||||
const id = parseInt(photoBtn.dataset.groupId, 10);
|
const id = parseInt(photoBtn.dataset.groupId, 10);
|
||||||
|
|||||||
@@ -120,8 +120,10 @@ function clearFiles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILES = 10;
|
const MAX_FILES = 10;
|
||||||
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
const DEFAULT_FILE_LIMIT_MB = 50;
|
||||||
const MAX_TOTAL_BYTES = 30 * 1024 * 1024;
|
const DEFAULT_TOTAL_LIMIT_MB = 200;
|
||||||
|
let MAX_FILE_BYTES = DEFAULT_FILE_LIMIT_MB * 1024 * 1024;
|
||||||
|
let MAX_TOTAL_BYTES = DEFAULT_TOTAL_LIMIT_MB * 1024 * 1024;
|
||||||
const BLOCKED_FILE_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
const BLOCKED_FILE_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
||||||
const MIME_EXT = {
|
const MIME_EXT = {
|
||||||
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp',
|
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp',
|
||||||
@@ -164,7 +166,7 @@ function pasteFileName(file) {
|
|||||||
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
|
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
const pad = n => String(n).padStart(2, '0');
|
const pad = n => String(n).padStart(2, '0');
|
||||||
const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
const stamp = `${todayIso()} ${nowHm().replace(':', '-')}-${pad(d.getSeconds())}`;
|
||||||
return `Вставка ${stamp}${extFromFile(file)}`;
|
return `Вставка ${stamp}${extFromFile(file)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,14 +174,25 @@ function totalUploadBytes() {
|
|||||||
return (capturedBlob ? capturedBlob.size : 0) + selectedFiles.reduce((sum, f) => sum + f.size, 0);
|
return (capturedBlob ? capturedBlob.size : 0) + selectedFiles.reduce((sum, f) => sum + f.size, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyUploadLimits(fileMb, totalMb) {
|
||||||
|
const f = parseInt(fileMb, 10);
|
||||||
|
const t = parseInt(totalMb, 10);
|
||||||
|
const fileLimit = Number.isFinite(f) && f >= 1 ? f : DEFAULT_FILE_LIMIT_MB;
|
||||||
|
const totalLimit = Number.isFinite(t) && t >= fileLimit ? t : Math.max(DEFAULT_TOTAL_LIMIT_MB, fileLimit);
|
||||||
|
MAX_FILE_BYTES = fileLimit * 1024 * 1024;
|
||||||
|
MAX_TOTAL_BYTES = totalLimit * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
function addFiles(list) {
|
function addFiles(list) {
|
||||||
const added = [];
|
const added = [];
|
||||||
const skipped = [];
|
const skipped = [];
|
||||||
|
const fileLimitMb = Math.round(MAX_FILE_BYTES / 1024 / 1024);
|
||||||
|
const totalLimitMb = Math.round(MAX_TOTAL_BYTES / 1024 / 1024);
|
||||||
for (const file of list) {
|
for (const file of list) {
|
||||||
if (selectedFiles.length >= MAX_FILES) { skipped.push('Можно прикрепить не более 10 файлов'); break; }
|
if (selectedFiles.length >= MAX_FILES) { skipped.push(`Можно прикрепить не более ${MAX_FILES} файлов`); break; }
|
||||||
if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше 10 МБ`); continue; }
|
if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше ${fileLimitMb} МБ`); continue; }
|
||||||
if (BLOCKED_FILE_EXT.test(extOf(file.name))) { skipped.push(`«${file.name}»: недопустимый тип файла`); continue; }
|
if (BLOCKED_FILE_EXT.test(extOf(file.name))) { skipped.push(`«${file.name}»: недопустимый тип файла`); continue; }
|
||||||
if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push('Суммарный размер файлов — не более 30 МБ'); break; }
|
if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push(`Суммарный размер файлов — не более ${totalLimitMb} МБ`); break; }
|
||||||
selectedFiles.push(file);
|
selectedFiles.push(file);
|
||||||
added.push(file.name);
|
added.push(file.name);
|
||||||
}
|
}
|
||||||
@@ -268,6 +281,7 @@ async function resolveModuleId(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
await initDateTime();
|
||||||
try {
|
try {
|
||||||
const list = await fetchModules('', 50);
|
const list = await fetchModules('', 50);
|
||||||
if (list.length) {
|
if (list.length) {
|
||||||
@@ -319,6 +333,7 @@ async function resolveModuleId(name) {
|
|||||||
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
|
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
|
||||||
const q = parseFloat(f.photo_capture_quality);
|
const q = parseFloat(f.photo_capture_quality);
|
||||||
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
|
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
|
||||||
|
applyUploadLimits(f.upload_file_limit_mb, f.upload_total_limit_mb);
|
||||||
if (f.camera_enabled === 'false') {
|
if (f.camera_enabled === 'false') {
|
||||||
const camBtns = document.getElementById('camBtns');
|
const camBtns = document.getElementById('camBtns');
|
||||||
if (camBtns) camBtns.style.display = 'none';
|
if (camBtns) camBtns.style.display = 'none';
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ function isImageFile(name) {
|
|||||||
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlayableVideoFile(name) {
|
||||||
|
return /\.(mp4|m4v|webm|ogv)$/i.test(name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoFile(name) {
|
||||||
|
return /\.(mp4|m4v|webm|ogv|mov|mkv|avi|mpeg|mpg|3gp|ts)$/i.test(name || '');
|
||||||
|
}
|
||||||
|
|
||||||
function mainPhotoPath(e) {
|
function mainPhotoPath(e) {
|
||||||
if (e.photo_path) return e.photo_path;
|
if (e.photo_path) return e.photo_path;
|
||||||
if (e.photos && e.photos.length) return e.photos[0].photo_path;
|
if (e.photos && e.photos.length) return e.photos[0].photo_path;
|
||||||
@@ -43,9 +51,16 @@ function mainPhotoPath(e) {
|
|||||||
function filesHTML(files) {
|
function filesHTML(files) {
|
||||||
if (!files || !files.length) return '';
|
if (!files || !files.length) return '';
|
||||||
return `<div class="entry-files">${files.map(f => {
|
return `<div class="entry-files">${files.map(f => {
|
||||||
const link = isImageFile(f.name)
|
const url = `${API}/api/files/${f.token}`;
|
||||||
? `<a class="f-link" href="#" data-img="${API}/api/files/${f.token}" title="Открыть">${esc(f.name)}</a>`
|
let link;
|
||||||
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`;
|
if (isImageFile(f.name)) {
|
||||||
|
link = `<a class="f-link" href="#" data-img="${url}" title="Открыть">${esc(f.name)}</a>`;
|
||||||
|
} else if (isPlayableVideoFile(f.name)) {
|
||||||
|
link = `<a class="f-link" href="${url}?play=1" data-video="${url}?play=1" data-video-name="${esc(f.name)}" data-video-token="${f.token}" title="Смотреть">${esc(f.name)}</a>`;
|
||||||
|
} else {
|
||||||
|
const note = isVideoFile(f.name) ? 'Скачать (браузер не умеет играть этот формат)' : 'Скачать';
|
||||||
|
link = `<a class="f-link" href="${url}" download title="${note}">${esc(f.name)}</a>`;
|
||||||
|
}
|
||||||
return `<span class="f-item">${link}<button class="f-detach" data-detach="${f.id}" title="Открепить файл">✕</button></span>`;
|
return `<span class="f-item">${link}<button class="f-detach" data-detach="${f.id}" title="Открепить файл">✕</button></span>`;
|
||||||
}).join('')}</div>`;
|
}).join('')}</div>`;
|
||||||
}
|
}
|
||||||
@@ -109,7 +124,7 @@ function gridEntryHTML(e) {
|
|||||||
<div class="group">${esc(e.group_name)}${e.module_name ? ' · ' + esc(e.module_name) : ''}</div>
|
<div class="group">${esc(e.group_name)}${e.module_name ? ' · ' + esc(e.module_name) : ''}</div>
|
||||||
<div class="desc">${esc(e.description)}${aiBadge(e)}</div>
|
<div class="desc">${esc(e.description)}${aiBadge(e)}</div>
|
||||||
${filesHTML(e.files)}
|
${filesHTML(e.files)}
|
||||||
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
<div class="time">${fmtFull(e.created_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||||||
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
||||||
@@ -133,7 +148,7 @@ function cardEntryHTML(e) {
|
|||||||
${filesHTML(e.files)}
|
${filesHTML(e.files)}
|
||||||
</div>
|
</div>
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
|
<span>${fmtFull(e.created_at)}</span>
|
||||||
<span>
|
<span>
|
||||||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||||||
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
||||||
@@ -205,7 +220,7 @@ function setView(v) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setDefaultDates() {
|
function setDefaultDates() {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = todayIso();
|
||||||
document.getElementById('dateFrom').value = today;
|
document.getElementById('dateFrom').value = today;
|
||||||
document.getElementById('dateTo').value = today;
|
document.getElementById('dateTo').value = today;
|
||||||
}
|
}
|
||||||
@@ -498,6 +513,9 @@ let enhanceSaving = false;
|
|||||||
let enhanceEngine = 'auto';
|
let enhanceEngine = 'auto';
|
||||||
let enhanceEngineLoaded = false;
|
let enhanceEngineLoaded = false;
|
||||||
let photoAiEnabled = false;
|
let photoAiEnabled = false;
|
||||||
|
let photoAiFaceMode = 'off';
|
||||||
|
let photoAiFaceModel = 'gfpgan';
|
||||||
|
let photoAiDevice = '';
|
||||||
let photoAiPollTimer = null;
|
let photoAiPollTimer = null;
|
||||||
let enhanceAiResultPath = null;
|
let enhanceAiResultPath = null;
|
||||||
let enhanceAiJobId = null;
|
let enhanceAiJobId = null;
|
||||||
@@ -603,6 +621,46 @@ function resetEnhanceZoom() {
|
|||||||
area.addEventListener('dblclick', resetEnhanceZoom);
|
area.addEventListener('dblclick', resetEnhanceZoom);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const PHOTO_AI_MODES = {
|
||||||
|
x2: { model: 'x2plus', face: 'off', face_model: null, title: 'Универсально (x2)' },
|
||||||
|
x4: { model: 'general-x4v3', face: 'off', face_model: null, title: 'Быстро (x4)' },
|
||||||
|
faces: { model: 'x2plus', face: 'face', face_model: null, title: 'Лица (восстановление)' },
|
||||||
|
facesbg: { model: 'x2plus', face: 'all', face_model: null, title: 'Лица + фон' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function photoAiMode() {
|
||||||
|
const sel = document.getElementById('enhanceAiMode');
|
||||||
|
const key = sel && PHOTO_AI_MODES[sel.value] ? sel.value : 'x2';
|
||||||
|
const m = { ...PHOTO_AI_MODES[key] };
|
||||||
|
m.face_model = m.face === 'off' ? null : photoAiFaceModel;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPhotoAiModeKey() {
|
||||||
|
if (photoAiFaceMode === 'all') return 'facesbg';
|
||||||
|
if (photoAiFaceMode === 'face') return 'faces';
|
||||||
|
return 'x2';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAiHint() {
|
||||||
|
const hint = document.getElementById('enhanceAiHint');
|
||||||
|
if (!hint) return;
|
||||||
|
const cpu = photoAiDevice.startsWith('cpu');
|
||||||
|
hint.textContent = cpu ? 'Фото-ИИ считает на CPU — лицевые режимы могут занять несколько минут' : '';
|
||||||
|
hint.style.display = photoAiEnabled && cpu ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPhotoAiDevice() {
|
||||||
|
if (!isAdmin()) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/api/photo-ai/health`, { headers: hdr() });
|
||||||
|
if (!r.ok) return;
|
||||||
|
const h = await r.json();
|
||||||
|
if (h && h.device) photoAiDevice = String(h.device);
|
||||||
|
updateAiHint();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadEnhanceEngine() {
|
async function loadEnhanceEngine() {
|
||||||
if (enhanceEngineLoaded) return;
|
if (enhanceEngineLoaded) return;
|
||||||
enhanceEngineLoaded = true;
|
enhanceEngineLoaded = true;
|
||||||
@@ -611,18 +669,31 @@ async function loadEnhanceEngine() {
|
|||||||
const f = await r.json();
|
const f = await r.json();
|
||||||
if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine;
|
if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine;
|
||||||
photoAiEnabled = f.photo_ai_enabled === 'true';
|
photoAiEnabled = f.photo_ai_enabled === 'true';
|
||||||
|
if (['off', 'face', 'all'].includes(f.photo_ai_face_mode)) photoAiFaceMode = f.photo_ai_face_mode;
|
||||||
|
if (['gfpgan', 'codeformer'].includes(f.photo_ai_face_model)) photoAiFaceModel = f.photo_ai_face_model;
|
||||||
|
const sel = document.getElementById('enhanceAiMode');
|
||||||
|
if (sel) sel.value = defaultPhotoAiModeKey();
|
||||||
|
loadPhotoAiDevice();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runPhotoAi() {
|
async function runPhotoAi() {
|
||||||
if (!enhanceEntryId || enhanceSaving) return;
|
if (!enhanceEntryId || enhanceSaving) return;
|
||||||
const entryId = enhanceEntryId;
|
const entryId = enhanceEntryId;
|
||||||
if (!confirm('ИИ-восстановление фото (Real-ESRGAN). Может занять до минуты на CPU. Продолжить?')) return;
|
const mode = photoAiMode();
|
||||||
|
const faceNote = mode.face === 'off'
|
||||||
|
? 'Может занять до минуты на CPU.'
|
||||||
|
: `Восстановление лиц (${mode.face_model}), на CPU может занять несколько минут.`;
|
||||||
|
if (!confirm(`ИИ-обработка фото: ${mode.title}. ${faceNote} Продолжить?`)) return;
|
||||||
enhanceSaving = true;
|
enhanceSaving = true;
|
||||||
const btn = document.getElementById('enhanceAiBtn');
|
const btn = document.getElementById('enhanceAiBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai`, { method: 'POST', headers: hdr() });
|
const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance-ai`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify({ model: mode.model, face: mode.face, face_model: mode.face_model }),
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err.error || 'Ошибка запуска ИИ');
|
throw new Error(err.error || 'Ошибка запуска ИИ');
|
||||||
@@ -630,12 +701,14 @@ async function runPhotoAi() {
|
|||||||
const { jobId } = await res.json();
|
const { jobId } = await res.json();
|
||||||
enhanceAiJobId = jobId;
|
enhanceAiJobId = jobId;
|
||||||
showEnhanceResult(false);
|
showEnhanceResult(false);
|
||||||
setEnhanceLoadingText('ИИ обрабатывает фото... (может занять до минуты)');
|
setEnhanceLoadingText(mode.face === 'off'
|
||||||
|
? 'ИИ обрабатывает фото... (может занять до минуты)'
|
||||||
|
: 'ИИ обрабатывает фото и восстанавливает лица...');
|
||||||
btn.textContent = '⏳ ИИ...';
|
btn.textContent = '⏳ ИИ...';
|
||||||
const result = await new Promise((resolve, reject) => {
|
const result = await new Promise((resolve, reject) => {
|
||||||
photoAiPollTimer = setInterval(async () => {
|
photoAiPollTimer = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai/${jobId}`, { headers: hdr() });
|
const r = await fetch(`${API}/api/entries/${entryId}/photo/enhance-ai/${jobId}`, { headers: hdr() });
|
||||||
if (!r.ok) throw new Error('Ошибка опроса статуса');
|
if (!r.ok) throw new Error('Ошибка опроса статуса');
|
||||||
const s = await r.json();
|
const s = await r.json();
|
||||||
if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); }
|
if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); }
|
||||||
@@ -715,7 +788,10 @@ function openEnhance(id) {
|
|||||||
orig.src = `${API}${mp}`;
|
orig.src = `${API}${mp}`;
|
||||||
document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none';
|
document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none';
|
||||||
loadEnhanceEngine().then(() => {
|
loadEnhanceEngine().then(() => {
|
||||||
document.getElementById('enhanceAiBtn').style.display = photoAiEnabled ? '' : 'none';
|
const show = photoAiEnabled ? '' : 'none';
|
||||||
|
document.getElementById('enhanceAiBtn').style.display = show;
|
||||||
|
document.getElementById('enhanceAiMode').style.display = show;
|
||||||
|
updateAiHint();
|
||||||
});
|
});
|
||||||
const canvas = document.getElementById('enhanceCanvas');
|
const canvas = document.getElementById('enhanceCanvas');
|
||||||
setEnhanceClip(50);
|
setEnhanceClip(50);
|
||||||
@@ -819,7 +895,7 @@ async function loadPhotoHistory() {
|
|||||||
</div>
|
</div>
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
<div style="font-size:.85rem">${esc(PHOTO_JOB_ACTION_LABELS[r.action] || r.action)}
|
<div style="font-size:.85rem">${esc(PHOTO_JOB_ACTION_LABELS[r.action] || r.action)}
|
||||||
<span style="color:var(--muted);font-size:.75rem;margin-left:6px">${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(new Date(time).toLocaleString('ru'))}</span>
|
<span style="color:var(--muted);font-size:.75rem;margin-left:6px">${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(fmtFull(time))}</span>
|
||||||
</div>
|
</div>
|
||||||
${err}
|
${err}
|
||||||
</div>
|
</div>
|
||||||
@@ -1153,7 +1229,7 @@ function renderEditAiStatus(e) {
|
|||||||
el.style.display = 'block';
|
el.style.display = 'block';
|
||||||
const changed = e.description_ai && e.description_original && e.description_ai !== e.description_original;
|
const changed = e.description_ai && e.description_original && e.description_ai !== e.description_original;
|
||||||
const parts = [AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`];
|
const parts = [AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`];
|
||||||
if (e.ai_checked_at) parts.push(new Date(e.ai_checked_at).toLocaleString('ru'));
|
if (e.ai_checked_at) parts.push(fmtFull(e.ai_checked_at));
|
||||||
let html = `<span>${esc(parts.join(' · '))}</span>`;
|
let html = `<span>${esc(parts.join(' · '))}</span>`;
|
||||||
if (changed) html += ` <button type="button" id="aiRevertBtn" class="ai-btn" style="font-size:.75rem" title="Вернуть исходный текст (до ИИ)">↩ Оригинал</button>`;
|
if (changed) html += ` <button type="button" id="aiRevertBtn" class="ai-btn" style="font-size:.75rem" title="Вернуть исходный текст (до ИИ)">↩ Оригинал</button>`;
|
||||||
html += ` <button type="button" id="aiRecheckBtn" class="ai-btn" style="font-size:.75rem" title="Проверить заново">⟳ Проверить</button>`;
|
html += ` <button type="button" id="aiRecheckBtn" class="ai-btn" style="font-size:.75rem" title="Проверить заново">⟳ Проверить</button>`;
|
||||||
@@ -1340,7 +1416,7 @@ async function exportCSV() {
|
|||||||
e.group_name,
|
e.group_name,
|
||||||
e.module_name || '',
|
e.module_name || '',
|
||||||
e.description.replace(/\n/g, ' '),
|
e.description.replace(/\n/g, ' '),
|
||||||
new Date(e.created_at).toLocaleString('ru'),
|
fmtFull(e.created_at),
|
||||||
e.photo_path ? `${API}${e.photo_path}` : ''
|
e.photo_path ? `${API}${e.photo_path}` : ''
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -1528,8 +1604,53 @@ document.getElementById('aiCorrectModal').addEventListener('click', e => {
|
|||||||
if (e.target === e.currentTarget) closeAiCorrect();
|
if (e.target === e.currentTarget) closeAiCorrect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function openVideo(url, name, token) {
|
||||||
|
const modal = document.getElementById('videoModal');
|
||||||
|
const player = document.getElementById('videoPlayer');
|
||||||
|
const title = document.getElementById('videoTitle');
|
||||||
|
const dl = document.getElementById('videoDownload');
|
||||||
|
if (!modal || !player) return;
|
||||||
|
player.src = url;
|
||||||
|
player.currentTime = 0;
|
||||||
|
title.textContent = name || 'Видео';
|
||||||
|
dl.href = `${API}/api/files/${token}`;
|
||||||
|
dl.setAttribute('download', '');
|
||||||
|
modal.classList.add('open');
|
||||||
|
const play = player.play();
|
||||||
|
if (play && typeof play.catch === 'function') play.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeVideo() {
|
||||||
|
const modal = document.getElementById('videoModal');
|
||||||
|
const player = document.getElementById('videoPlayer');
|
||||||
|
if (!modal || !player) return;
|
||||||
|
try { player.pause(); } catch {}
|
||||||
|
player.removeAttribute('src');
|
||||||
|
player.load();
|
||||||
|
modal.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('videoModal').addEventListener('click', e => {
|
||||||
|
if (e.target === e.currentTarget) closeVideo();
|
||||||
|
});
|
||||||
|
document.getElementById('videoClose').addEventListener('click', closeVideo);
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (document.getElementById('videoModal').classList.contains('open')) closeVideo();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const v = e.target.closest('[data-video]');
|
||||||
|
if (!v) return;
|
||||||
|
e.preventDefault();
|
||||||
|
openVideo(v.dataset.video, v.dataset.videoName, v.dataset.videoToken);
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('exportCsvBtn').addEventListener('click', exportCSV);
|
document.getElementById('exportCsvBtn').addEventListener('click', exportCSV);
|
||||||
document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate);
|
document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate);
|
||||||
|
document.getElementById('lessonReportBtn').addEventListener('click', () => {
|
||||||
|
openLessonModal({ groupId: document.getElementById('groupFilter').value });
|
||||||
|
});
|
||||||
document.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
|
document.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
|
||||||
document.getElementById('editCancelBtn').addEventListener('click', closeEdit);
|
document.getElementById('editCancelBtn').addEventListener('click', closeEdit);
|
||||||
document.getElementById('linkCreateCloseBtn').addEventListener('click', closeLinkCreate);
|
document.getElementById('linkCreateCloseBtn').addEventListener('click', closeLinkCreate);
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
const PAGE_SIZE = 20;
|
||||||
|
let page = 1;
|
||||||
|
let total = 0;
|
||||||
|
let searchTimer = null;
|
||||||
|
let groupsLoaded = false;
|
||||||
|
|
||||||
|
function lessonDateText(iso) {
|
||||||
|
const s = String(iso || '').slice(0, 10);
|
||||||
|
if (!s) return '';
|
||||||
|
return fmtShortIso(s) || s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lessonWhenText(r) {
|
||||||
|
const date = lessonDateText(r.lesson_date);
|
||||||
|
const time = r.lesson_time ? fmtHmStr(r.lesson_time) : '';
|
||||||
|
return time ? `${date}, ${time}` : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lessonAuthorText(r) {
|
||||||
|
const name = r.author_name || r.author_username;
|
||||||
|
if (!name) return '';
|
||||||
|
const upd = r.updated_at && r.created_at && new Date(r.updated_at).getTime() - new Date(r.created_at).getTime() > 60000;
|
||||||
|
return upd ? `${name} · изменён ${notifyTime(r.updated_at)}` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lessonAiBadgeHtml(r) {
|
||||||
|
const st = r.ai_status;
|
||||||
|
if (!st || st === 'none') return '';
|
||||||
|
if (st === 'pending' || st === 'processing') return `<span class="ai-badge ai-badge-ic ai-badge-processing" title="ИИ проверяет текст по шаблону"><i data-lucide="loader"></i></span>`;
|
||||||
|
if (st === 'done' && r.text_ai) return '<span class="ai-badge ai-badge-done" title="ИИ оформил текст по шаблону">ИИ</span>';
|
||||||
|
if (st === 'skipped') return '<span class="ai-badge ai-badge-skipped" title="Текст уже соответствует шаблону">По шаблону</span>';
|
||||||
|
if (st === 'reverted') return '<span class="ai-badge ai-badge-reverted" title="Возвращена исходная версия текста">Оригинал</span>';
|
||||||
|
if (st === 'error') return `<span class="ai-badge ai-badge-error" title="Проверка по шаблону не удалась: ${esc(r.ai_error || 'ошибка ИИ')}">Ошибка ИИ</span>`;
|
||||||
|
return `<span class="ai-badge ai-badge-ic ai-badge-${esc(st)}" title="Статус ИИ: ${esc(st)}">${esc(st)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lessonItemHtml(r) {
|
||||||
|
const isToday = String(r.lesson_date).slice(0, 10) === lessonTodayIso();
|
||||||
|
const author = lessonAuthorText(r);
|
||||||
|
const canRevert = !!r.text_original && r.text_original !== r.text;
|
||||||
|
return `
|
||||||
|
<div class="lesson-item" data-lesson="${r.id}">
|
||||||
|
<div class="lesson-item-head">
|
||||||
|
<span class="lesson-group">${esc(r.group_name || '')}</span>
|
||||||
|
<span class="lesson-when"><i data-lucide="clock-3"></i>${esc(lessonWhenText(r))}</span>
|
||||||
|
${isToday ? '<span class="lesson-badge today">сегодня</span>' : ''}
|
||||||
|
${lessonAiBadgeHtml(r)}
|
||||||
|
<div class="lesson-actions">
|
||||||
|
<button type="button" class="btn-link" data-versions="${r.id}">История</button>
|
||||||
|
${canRevert ? `<button type="button" class="btn-link" data-revert="${r.id}" title="Вернуть исходный текст тьютора">↩ Оригинал</button>` : ''}
|
||||||
|
<button type="button" class="btn-link" data-edit="${r.id}">Редактировать</button>
|
||||||
|
${isAdmin() ? `<button type="button" class="btn-link danger" data-del="${r.id}">Удалить</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-text">${esc(r.text || '')}</div>
|
||||||
|
${author ? `<div class="lesson-author">${esc(author)}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLessons(items) {
|
||||||
|
const list = document.getElementById('lessonsList');
|
||||||
|
const state = document.getElementById('lessonsState');
|
||||||
|
list.innerHTML = items.length
|
||||||
|
? items.map(lessonItemHtml).join('')
|
||||||
|
: '<div class="empty">Отчётов пока нет</div>';
|
||||||
|
if (state) {
|
||||||
|
state.textContent = total ? `Показано ${items.length} из ${total}` : '';
|
||||||
|
}
|
||||||
|
renderIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLessons() {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
const gid = document.getElementById('groupFilter').value;
|
||||||
|
const df = document.getElementById('dateFrom').value;
|
||||||
|
const dt = document.getElementById('dateTo').value;
|
||||||
|
const search = document.getElementById('searchInput').value.trim();
|
||||||
|
if (gid) q.set('group_id', gid);
|
||||||
|
if (df) q.set('date_from', df);
|
||||||
|
if (dt) q.set('date_to', dt);
|
||||||
|
if (search) q.set('search', search);
|
||||||
|
q.set('limit', String(PAGE_SIZE));
|
||||||
|
q.set('offset', String((page - 1) * PAGE_SIZE));
|
||||||
|
const list = document.getElementById('lessonsList');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/lesson-reports?${q.toString()}`, { headers: hdr() });
|
||||||
|
if (!res.ok) {
|
||||||
|
list.innerHTML = `<div class="empty">Ошибка загрузки (${res.status})</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
total = Number(data.total) || 0;
|
||||||
|
const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
if (page > maxPage) { page = maxPage; return loadLessons(); }
|
||||||
|
renderLessons(data.items || []);
|
||||||
|
renderPager(document.getElementById('pager'), page, maxPage, p => { page = p; loadLessons(); });
|
||||||
|
} catch {
|
||||||
|
list.innerHTML = '<div class="empty">Ошибка сети</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroupFilter() {
|
||||||
|
if (groupsLoaded) return;
|
||||||
|
const sel = document.getElementById('groupFilter');
|
||||||
|
const groups = await loadLessonGroups();
|
||||||
|
groups.forEach(g => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = g.id;
|
||||||
|
o.textContent = g.branch_name ? `${g.name} (${g.branch_name})` : g.name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
groupsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLesson(id) {
|
||||||
|
if (!confirm('Удалить отчёт о занятии?')) return;
|
||||||
|
const res = await fetch(`${API}/api/lesson-reports/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) { showToast(data.error || 'Не удалось удалить отчёт'); return; }
|
||||||
|
showToast('Отчёт удалён');
|
||||||
|
loadLessons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revertLessonAi(id) {
|
||||||
|
if (!confirm('Вернуть исходный текст отчёта (до оформления по шаблону)?')) return;
|
||||||
|
const res = await fetch(`${API}/api/lesson-reports/${id}/ai/revert`, { method: 'POST', headers: hdr() });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) { showToast(data.error || 'Не удалось вернуть оригинал'); return; }
|
||||||
|
showToast('Возвращён исходный текст отчёта');
|
||||||
|
loadLessons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLessonReportSaved() {
|
||||||
|
loadLessons();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('lessonsList').addEventListener('click', e => {
|
||||||
|
const edit = e.target.closest('[data-edit]');
|
||||||
|
if (edit) { openLessonModal({ id: edit.dataset.edit }); return; }
|
||||||
|
const versions = e.target.closest('[data-versions]');
|
||||||
|
if (versions) { openLessonVersions(versions.dataset.versions); return; }
|
||||||
|
const revert = e.target.closest('[data-revert]');
|
||||||
|
if (revert) { revertLessonAi(revert.dataset.revert); return; }
|
||||||
|
const del = e.target.closest('[data-del]');
|
||||||
|
if (del) { deleteLesson(del.dataset.del); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('addLessonBtn').addEventListener('click', () => {
|
||||||
|
openLessonModal({ groupId: document.getElementById('groupFilter').value });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadLessons(); });
|
||||||
|
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadLessons(); });
|
||||||
|
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadLessons(); });
|
||||||
|
document.getElementById('searchInput').addEventListener('input', () => {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => { page = 1; loadLessons(); }, 350);
|
||||||
|
});
|
||||||
|
|
||||||
|
let lessonLiveSource = null;
|
||||||
|
|
||||||
|
function connectLessonLive() {
|
||||||
|
if (lessonLiveSource) { lessonLiveSource.close(); lessonLiveSource = null; }
|
||||||
|
const connect = () => {
|
||||||
|
const t = sessionStorage.getItem('authToken');
|
||||||
|
if (!t) return;
|
||||||
|
lessonLiveSource = new EventSource(`${API}/api/events?token=${encodeURIComponent(t)}`);
|
||||||
|
lessonLiveSource.addEventListener('lesson_report_status', () => loadLessons());
|
||||||
|
lessonLiveSource.onerror = () => {
|
||||||
|
if (lessonLiveSource) { lessonLiveSource.close(); lessonLiveSource = null; }
|
||||||
|
setTimeout(connect, 5000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
connect();
|
||||||
|
window.addEventListener('beforeunload', () => { if (lessonLiveSource) lessonLiveSource.close(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectLessonLive();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (!(await checkAuth())) return;
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
await loadGroupFilter();
|
||||||
|
loadLessons();
|
||||||
|
})();
|
||||||
@@ -41,10 +41,10 @@ async function loadLinks() {
|
|||||||
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
||||||
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
|
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
|
||||||
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
|
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
|
||||||
${expired ? ` · <span style="color:#ef4444;font-weight:600">срок действия истёк ${new Date(l.expires_at).toLocaleDateString('ru')}</span>` : l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
|
${expired ? ` · <span style="color:#ef4444;font-weight:600">срок действия истёк ${fmtDateFull(l.expires_at)}</span>` : l.expires_at ? ` · истекает ${fmtDateFull(l.expires_at)}` : ''}
|
||||||
${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''}
|
${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''}
|
||||||
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
||||||
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
· создана ${fmtFull(l.created_at)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
|
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
|
||||||
|
|||||||