16 Commits
Author SHA1 Message Date
dev 19b2365b4c fix(backup): ссылка на архив переживает F5 и повторное скачивание
HEAD-проба в downloadBackup() сжигала одноразовый тикет: Express 4
прогоняет HEAD через GET-хендлер /api/backup/:token, который удалял
тикет и файл до отдачи архива, поэтому настоящий GET всегда получал
404 «Ссылка на бэкап устарела».

- server.js: GET /api/backup/:token больше не удаляет тикет и файл —
  ссылка живёт BACKUP_TTL_MS (30 мин), Range-докачка работает
- вынес dropBackupTicket(), fs.rmSync обёрнут в try/catch
- BACKUP_TICKETS_MAX = 3: лишние тикеты вычищаются по возрасту
- sweepBackupStorage() режет с запасом 5 минут сверх TTL
- settings.js: убрана HEAD-проба, в #backupStatus рендерится реальная
  кликабельная ссылка вместо невидимого синтетического <a>
- AGENTS.md: раздел 8 — новое поведение + предупреждение про HEAD
2026-10-04 09:07:16 +03:00
dev 76d36e5c35 feat(backup): формат бэкапа v2 — audit/уведомления/баны, counts и одноразовый тикет
Валидация и нормализация restore вынесены из server.js в backup-restore.js,
покрыты юнит-тестами backup.selftest.js (30+ проверок, без стенда).

В бэкап добавлены audit_log, notifications, notification_reads, banned_ips,
счётчики counts, метаданные приложения и версия формата (принимаются 1..2).
Тикет бэкапа стал одноразовым: файл удаляется сразу после отдачи,
uploadBackup фильтрует расширения. sessions в бэкап не входит намеренно.

sweepOrphanedUploads учитывает аватары, обложки, оригиналы фото и photo_jobs —
иначе restore сносил файлы сразу после восстановления.
storage.getStream при STORAGE_LOCAL_FALLBACK читает локальный файл, а не S3.
2026-10-04 01:36:58 +03:00
dev 32aabe9a80 fix(lesson-ai): обновление промпта у существующих БД + правило в AGENTS.md
INSERT ... ON CONFLICT (key) DO NOTHING вставляет значение только если
ключа ещё нет, поэтому новый шаблон промпта доезжал лишь до свежих
установок — у всех, кто уже пользовался разделом sec-lesson-ai, в settings
оставался старый промпт.

- в db/migration.sql добавлен идемпотентный
  UPDATE settings SET value = '<новый промпт>'
  WHERE key = 'lesson_ai_prompt' AND value IN ('<старый дефолт>')
  по образцу уже существующей миграции для ai_prompt
- условие по value IN (...) обязательно: без него миграция затёрла бы
  промпт, отредактированный админом в UI
- в db/init.sql UPDATE не добавляется: файл выполняется только на пустой БД
- AGENTS.md, раздел 2: правило «Сид настроек» (DO NOTHING не обновляет
  существующие значения) и «Проверка сида» (прогон в транзакции с откатом)
- AGENTS.md, раздел 3d: смена шаблона промпта — четыре правки, а не три;
  все текстовые копии должны быть побайтово идентичны LESSON_AI_DEFAULT_PROMPT

Проверено на postgres:16 в транзакции с откатом, ON_ERROR_STOP=1
- старый дефолт (935 симв.) -> миграция -> новый (2348 симв.)
- повторный прогон ничего не меняет (идемпотентно)
- кастомный промпт админа миграцией не затрагивается
2026-10-04 00:58:12 +03:00
dev 2363d099d2 feat(lesson-ai): новый шаблон промпта + предупреждение о дубле отчёта
- заменён дефолтный промпт редактуры отчёта о занятии на формат
  «сообщение тьютора»: связный текст от 3-го лица, 1 абзац 2–4
  предложения, начало «На занятии ребята …», практика «В конце занятия …»,
  без группы/даты/времени и markdown, обращение на «вы» убрано, «они» → «каждый»
- синхронизированы три копии промпта: LESSON_AI_DEFAULT_PROMPT в server.js
  и сид lesson_ai_prompt в db/init.sql и db/migration.sql; добавлен пример
  «плохо → хорошо» с запретом переносить факты примера
- дубль отчёта в модалке создания больше не автозаполняет форму: показывается
  баннер #lessonDup с кнопкой перехода к существующему отчёту
- проверка на дубль срабатывает и при смене даты (#lessonDate), защищена
  счётчиком поколений lessonDupSeq от гонки при быстрой смене группы/даты
- стили .lesson-dup в public/admin.css, уточнена подсказка в карточке
  шаблона отчёта на public/settings.html
- DDL не менялся: ни таблиц, ни колонок, ни индексов
2026-10-04 00:54:23 +03:00
dev b931c0a760 feat(lesson-ai): проверка отчёта о занятии по шаблону + история версий
Галочка «Проверить по шаблону» в окне отчёта отправляет текст модели:
совпал с шаблоном — остаётся как есть (skipped), не совпал — переписывается
в деловом виде (done). Обработка идёт в фоне, HTTP-запрос не ждёт модель,
оригинал тьютора сохраняется в text_original.

- схема: text_original/text_ai/ai_status/ai_checked_at/ai_error в
  lesson_reports, таблица lesson_report_versions, ensureLessonReportsTable()
- настройки lesson_ai_enabled и lesson_ai_prompt (раздел sec-lesson-ai),
  значения только 'true'/'false'
- worker.js: createLessonReportChecker (FOR UPDATE OF lr SKIP LOCKED,
  до 3 попыток), хук назовён notifyEvent — notify в createPhotoEnhanceWorker
  уже занят будильником
- server.js: wakeLessonAiWorker, onLessonAiDone (версия, аудит с diff,
  уведомление lesson.ai.formatted, SSE lesson_report_status), маршруты
  /versions, /versions/:id/restore и /ai/revert
- aiComplete вместо aiCorrectText: общий вызов модели с таймаутом
- бэкап/восстановление: lesson_reports и lesson_report_versions в payload
- фронтенд: openLessonVersions/restoreLessonVersion в admin.js, бейджи
  статусов в lessons.js, лейблы аудита, renderAuditPager
- docs: раздел 3d в AGENTS.md и Agent Workflow, пункт в README
- тесты: контракт lesson-report и настройки уведомления в api.smoketest.js
2026-10-04 00:12:24 +03:00
dev a84f33307e feat(lessons): отчёты о занятиях — таблица, CRUD, страница и блок на дашборде
Добавлена сущность «что прошли на занятии»:
- lesson_reports (init.sql + migration.sql + ensureLessonReportsTable)
- GET/POST /api/lesson-reports, PUT/DELETE /api/lesson-reports/:id
  с branchScope, уникальностью (group_id, lesson_date) и лимитом текста
- уведомление lesson.report (NOTIFY_TYPES + настройка + иконка)
- кэш-префикс lessons: + инвалидация stats:/dashboard:
- восстановление lesson_reports в normalizeRestoreData
- блок recent_lessons в /api/dashboard

Фронтенд:
- public/lessons.html + public/js/lessons.js — список с фильтрами и правкой
- openLessonModal в admin.js — общая модалка из журнала и дашборда
- кнопки в журнале и быстрые действия дашборда

Также исправлен сдвиг индексов параметров в notificationsScope —
$1 уходил повторно в список филиалов из-за params.push без смещения.
2026-10-03 23:33:45 +03:00
dev 0dc8ffdb87 feat(export): фото ученика и логотип в экспорте профиля
- /api/export/student: выборка student_photos, отдельный блок photos/ и
  метаданные student_photos в data.json, счётчик в totals
- логотип из system_logo кладётся в корень архива и подставляется в шапку
- student-report.js: секция «Фотографии ученика», логотип в топбаре,
  навигация ограничена 5 пунктами, пустые секции (Опыт работы) не рендерятся
- главное фото профиля/обложки приоритетно берётся из личных фото ученика
2026-10-03 22:57:35 +03:00
dev 2377707297 feat(students): аватары в списке студентов с превью и лайтбоксом
Круглый аватар слева от поля имени: миниатюра главного фото через thumbSrc, иначе инициалы. Клик по фото открывает imgModal с полным размером, клик по пустому — карточку профиля для загрузки. Аватар синхронизируется после загрузки, смены и удаления фото.
2026-10-03 21:33:25 +03:00
dev 70b0c7ae9b feat: env-driven upload limits + request timeout, inline video playback, range requests
- Add UPLOAD_FILE_LIMIT_MB/UPLOAD_TOTAL_LIMIT_MB env (defaults 50/200), compute request timeout from total limit or UPLOAD_REQUEST_TIMEOUT_MS
- Expose upload limits via /api/public-settings and sync in frontend (remove hardcoded 50MB assumption)
- Add byte-range support in storage (getRange/streamRangeTo) and serve Content-Range/Accept-Ranges for S3/local
- Implement inline playable video delivery for browser formats (mp4/m4v/webm/ogv) with ?play=1, range requests, proper 206/416
- Add video modal in journal UI with player and download fallback
- Update docs (AGENTS.md/PRD.md/README.md), styles for video modal, add instructions/TODO.md and screenshots
- Extend MIME types for media
2026-10-03 12:00:48 +03:00
dev 104bdc4f49 feat(uploads): лимиты загрузки в env, 50 МБ на файл и 200 МБ на запись
Лимиты были захардкожены в четырёх местах фронтенда и в константах multer,
из-за чего расходились с текстами ошибок на сервере.

- UPLOAD_FILE_LIMIT_MB (50) и UPLOAD_TOTAL_LIMIT_MB (200) читаются из env;
  оба multer-конфига (upload, adminUpload) берут fileSize из них, тексты
  ошибок собираются из тех же констант вместо литералов
- UPLOAD_REQUEST_TIMEOUT_MS снимает дефолт Node в 5 минут: считается как
  UPLOAD_TOTAL_LIMIT_MB * 7500, иначе 200 МБ по мобильной сети не успевают
- GET /api/public-settings отдаёт upload_file_limit_mb / upload_total_limit_mb,
  фронтенд читает их вместо собственных констант

Проверено на живом стеке: 20 МБ и 180 МБ суммарно принимаются, 55 МБ и
225 МБ отклоняются с верными сообщениями, скачивание 45 МБ из S3 совпадает
по sha256 с оригиналом, api.smoketest.js — 57 PASS / 0 FAIL.
2026-10-03 10:38:36 +03:00
dev 449b86955e feat(groups): расширенный экспорт фото — галерея и оригиналы 2026-10-02 13:07:47 +03:00
dev 55c4b281c3 feat(groups): ZIP-архив группы с выбором категорий и режима фото
Форма архива файлов группы на /groups.html получила галочки выгрузки:
- «Работы (файлы проектов)» — включена по умолчанию
- «Фото записи» — выключена по умолчанию
- режим фото «Последние» / «Все», по умолчанию «Последние»;
  радиокнопки блокируются, когда «Фото записи» выключена

Дефолты периода: дата «с» пустая, дата «по» — сегодня.

GET /api/groups/:id/export/files принимает include_files,
include_photos и photos_mode:
- latest — только entries.photo_path, то главное обработанное фото записи
- all — плюс вся галерея записи из entry_photos
- 400, если обе категории выключены

Запросы к БД для отключённых категорий не выполняются; в data.json
и в аудит добавлены options и раздельные счётчики works/photos.

В аудите добавлена метка export.group_files.

Проверено на стенде: группа с многофото-записями даёт 4 фото в режиме
«последние» и 6 в режиме «все» (совпадает с БД: 4 главных + 2 галереи),
категории комбинируются независимо. api.smoketest.js 57/57,
diff.selftest.js 16/16.
2026-10-02 12:48:36 +03:00
dev 45033bffa8 feat(photo-ai): Stage 5 — фронтенд фото-ИИ (режимы, статус, метаданные)
Только фронтенд: 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).

journal.html + journal.js:
- селект режима рядом с «🤖 ИИ»: Универсально (x2) / Быстро (x4) / Лица /
  Лица + фон; PHOTO_AI_MODES — единственная таблица режим→{model, face}
- значение уходит в POST .../enhance-ai телом {model, face, face_model};
  face_model подставляется только когда face !== 'off'
- дефолт селекта из photo_ai_face_mode (face=all → facesbg, face=face → faces)
- подсказка про медленный CPU: loadPhotoAiDevice() читает
  GET /api/photo-ai/health (только для админа)
- текст подтверждения и надпись «ИИ обрабатывает…» различают апскейл и face-режим
- кнопка и селект скрываются вместе при photo_ai_enabled === 'false' (I3)

settings.html + settings.js:
- новый раздел «Фото-ИИ»: режим по умолчанию, модель лиц, желаемое устройство
- read-only статус из /api/photo-jobs/status: фактическое устройство,
  device_name, VRAM, модели/модели лиц/загруженные; жёлтым — расхождение с
  желаемым устройством, красным — недоступность и незаданный PHOTO_AI_URL
- renderStackInfo() рисует карточку «Фото-ИИ» из stack.photo_ai
- новые id в DIRTY_FIELDS и в payload PUT /api/settings
- photo_ai_device_pref документирующий: фактическое устройство задаёт
  PHOTO_AI_DEVICE в контейнере

worker.html + worker.js:
- PHOTO_ACTION_LABELS: ai_face «ИИ + лица», ai_upscale «ИИ-апскейл»
- в модалке сравнения — чипы с моделью, режимом лиц, найденными лицами,
  устройством, временем обработки и предупреждениями
- строка состояния учитывает service.reachable === false («задания ждут»)
  и дописывает «· расчёт на <device>»

audit.js: 10 меток для кодов, которые раньше показывались сырыми (профиль и
фото ученика, главное фото, фото модуля, удаление/очистка уведомлений, логотип).

Проверено: node --check для всех четырёх js. Приёмка на живом стеке в этом
изменении не гонялась — чекбокс приёмки Stage 5 в TODO_PHOTO_FACE_AI.md
оставлен пустым, там же журнал раздела 5 с замечаниями для Stage 6.
2026-09-30 11:15:54 +03:00
dev 884188e9ec feat(photo-ai): Stage 4 — face-режим, апскейл-модели и метаданные в аудите
Воркер и сервер принимают параметры ИИ-обработки фото: модель апскейла
(x2plus / general-x4v3 / animevideo-v3), режим лиц (off / face / all),
модель лиц (gfpgan / codeformer) и strength. Пустое тело запроса ведёт себя
как раньше: action='ai', params=NULL (инвариант I2).

worker.js:
- таймаут выбирается по params.face: PHOTO_AI_TIMEOUT_MS для апскейла,
  PHOTO_AI_FACE_TIMEOUT_MS (600000) для face-режима
- runAiEnhance шлёт model/face/face_model/strength и понимает оба
  контракта: JSON с image_base64 и сырой image/jpeg старого сервиса
- тело не-2xx ответа больше не выбрасывается: readErrorBody() добавляет
  причину к сообщению, иначе оператор видит «ИИ-сервис ответил 400» без
  объяснения
- applyResult пишет в аудит model/face/face_model/device/faces_found/
  elapsed_ms/warnings и выбирает текст уведомления по факту режима;
  warnings видны оператору, если лица не нашлись
- CONFIG: + face_timeout_ms, default_model, face_model

server.js:
- POST /api/entries/:id/photo/enhance-ai принимает и валидирует тело до
  запроса записи — невалидный вход даёт 400, а не 404/500
- PHOTO_JOB_ACTIONS вынесен на уровень модуля, + ai_face и ai_upscale
- GET /api/photo-ai/health (requireAdmin) — прямой прокси /health
- photoAiHealth(timeoutMs), в «Статусе стека» вызывается с 2000 мс
- getStackInfo(): блок photo_ai (engine, host, device, vram, модели)
- настройки photo_ai_face_mode / photo_ai_face_model / photo_ai_device_pref
  с валидацией в PUT /api/settings, дефолты в init.sql, migration.sql,
  public-settings и ensurePhotoJobsTable()

Приёмка (живой стек, CPU + отдельно CUDA) — в TODO_PHOTO_FACE_AI.md,
журнал раздела 4: I1 байт-в-байт 5/5 и совпадение sha256 с raw-путём,
I2, I3 при пустом PHOTO_AI_URL, I5 на обрыве и на 503 с Retry-After,
7 невалидных тел → 400, api.smoketest.js 57 PASS.
2026-09-29 15:08:46 +03:00
dev 4c63a46d24 fix(photo-ai): лестница OOM на CUDA + приёмка face-режима и GPU-оверрайда
Stage 3 закрыт. Face-режим (off/face/all, strength, warnings) был написан в Stage 1;
здесь доведена приёмка и найден баг, который невозможно было увидеть без GPU-прогона.

Главное: realesные OOM никогда не доходили до run_guarded. И realessrgan/utils.py, и
gfpgan/utils.py ловят RuntimeError вокруг вызова сети и идут дальше
(`except RuntimeError as error: print('Error', error)`), поэтому на нехватку памяти
realesrgan падал уже не RuntimeError, а UnboundLocalError на присваивании
output_tile. Наружу уходил голый 500 «Internal Server Error»: ни лестницы тайлов,
ни деградации на CPU, ни внятного текста. В gfpgan это было тихое ухудшение —
при OOM лицо молча оставалось исходным, а задание уходило в «успех».

Починка: guard_forward() оборачивает forward сетей, которые строим мы
(RealESRGANer.model, restorer.gfpgan, CodeFormer net) и превращает OOM-RuntimeError
в TileOOM. TileOOM не наследует RuntimeError, поэтому проглатывающие except его
пропускают; is_oom и обе точки run_guarded ловят его явно. Обёртка вешается на
экземпляр, идемпотентна по флагу _photo_ai_guarded и не трогает класс.

Также добавлены два предупреждения из чек-листа, которых в коде не было: вход меньше
320×320 (лица могут не найтись) и CodeFormer на не-CUDA. Предупреждение «лица не
найдены» и деградация на CPU были на месте и не менялись.

Проверено на RTX 3050 Laptop (4096 МБ, драйвер 615.71.09, CUDA 12.6):
- tile=2048, x2plus face=all, полное фото: до правки 500 + UnboundLocalError,
  после 200 за 28.3 с с единственным warning «не хватило памяти при tile=2048»;
- инъекция OOM: лестница 2048 → 1024 → 512, затем переход на CPU (device: cpu,
  half: false) и успешный повтор; при повторе уже на CPU — честная 500 с подсказкой
  про PHOTO_AI_TILE / PHOTO_AI_MAX_PIXELS;
- 640×480: off 1.0 с / face 3.8 с / all 2.2 с, faces_found=6; фото без лиц даёт
  faces_found=0 и байты, равные face=off;
- I1: 6/6 MATCH байт-в-байт против Stage 2 на CPU (jpg/.jpeg/png+70/webp+100/x4v3/anime).

docker-compose.gpu.yml: GPU выдаётся через CDI (device_ids nvidia.com/gpu=all) —
не требует правки /etc/docker/daemon.json и перезапуска демона, в отличие от
классического резервирования driver: nvidia. TORCH_VARIANT cu124 → cu126: в индексе
cu124 последний torch 2.6.0, а cu126 даёт те же 2.14.0/0.29.0, что и CPU-образ, так
что варианты сборки отличаются только CUDA-библиотеками. Образ тегируется отдельно
(whatido-photo-ai:cu126), чтобы сборка GPU-варианта не перетирала CPU-образ
whatido-photo-ai:latest — откат остаётся обычным docker compose up -d photo-ai.

README: раздел «Запуск на NVIDIA GPU» с установкой NVIDIA Container Toolkit и генерацией
CDI-спеки, оговорками про 4 ГБ VRAM (x2plus + gfpgan влезают, general-x4v3 тяжелее,
LOAD_ALL=1 лучше не включать) и описанием параметров /enhance. .env.example: команда
GPU-запуска и рекомендация по PHOTO_AI_TILE. Журналы раздела 3 и GPU-прогона — в
TODO_PHOTO_FACE_AI.md.

worker.js, server.js, схема БД и фронтенд не тронуты — они в Stage 4…6.
2026-09-29 12:02:03 +03:00
dev 7367d66ac3 build(photo-ai): CPU/GPU сборка, gfpgan+facexlib без dev-зависимостей, healthcheck
Stage 2: photo-ai/Dockerfile получил ARG TORCH_VARIANT/TORCH_INDEX (один образ,
cu124 — вариант сборки) и явные пины gfpgan==1.3.8 / facexlib==0.3.0 через
--no-deps: tb-nightly и yapf (dev-зависимости gfpgan/basicsr) больше не попадают
в рантайм, matplotlib остаётся как зависимость filterpy (требование facexlib).
Патч basicsr/data/degradations.py сохранён байт-в-байт — без него basicsr падает
на torch >= 2.0.

Пин numpy<2 был невыполним: opencv-python-headless 5.0.0.93 требует numpy >= 2.
Зафиксирована фактическая версия numpy==2.2.6 в общем вызове pip install, а
opencv-python (не-headless) больше не ставится — раньше он приходил через gfpgan
и перезаписывал headless-сборку (активным был cv2 с GUI: QT5). I1 после этого
перепроверен: 5/5 MATCH байт-в-байт против эталона Stage 0.

ENV PHOTO_AI_MODELS_DIR=/models, MODEL_PATH остаётся валидным алиасом.
COPY vendor/ ./vendor/ + vendor/.gitkeep — вендоренный CodeFormer (D5)
подхватится из sys.path без правок Dockerfile.

docker-compose.yml: у photo-ai новые env (DEVICE, TILE, FACE_MODEL, LOAD_ALL,
JPEG_QUALITY, MODELS_DIR) и healthcheck со start_period 300s; MAX_INPUT_PIXELS
заменён на канонический PHOTO_AI_MAX_PIXELS (старое имя читается app.py как алиас).
У app — PHOTO_AI_FACE_MODEL и PHOTO_AI_FACE_TIMEOUT_MS (воркер читает их в Stage 4).
Новый docker-compose.gpu.yml (по образцу minio): TORCH_VARIANT=cu124,
PHOTO_AI_DEVICE=cuda, deploy.resources.reservations.devices для nvidia.

.env.example и таблица переменных README описывают ровно те переменные, которые
теперь подставляет compose; блок про GPU и NVIDIA Container Toolkit — Stage 6 (D6).

Проверено на живом стеке: сборка EXIT=0 (2.63 ГБ), /health отдаёт device=cpu,
face_models=[gfpgan], контейнер healthy, /api/photo-jobs/status отдаёт
service.device, I3 (пустой PHOTO_AI_URL -> 503 + 8 маршрутов 200), I4
(PHOTO_AI_DEVICE=cuda без CUDA -> WARN + CPU), docker compose config валиден для
базы, minio и gpu. GPU-пуск не выполнялся: nvidia-ctk на хосте отсутствует.
2026-09-29 09:35:40 +03:00
103 changed files with 4523 additions and 555 deletions
+32 -1
View File
@@ -4,6 +4,18 @@ DB_PASSWORD=случайная-длинная-строка
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 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. Обязателен, если Redis включён в docker-compose.
REDIS_PASSWORD=замените-на-длинный-секрет
@@ -36,14 +48,33 @@ CLOUDFLARE_TUNNEL_URL=http://app:3003
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
WG_HANDSHAKE_TIMEOUT=60
# === ИИ-улучшение фото (контейнер photo-ai, Real-ESRGAN) ===
# === ИИ-улучшение фото (контейнер photo-ai, Real-ESRGAN + GFPGAN) ===
# По умолчанию фото-ИИ включено: сервис 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
# Устройство инференса: 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
+74 -8
View File
@@ -2,6 +2,8 @@
This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly.
> **Обязательное правило:** разведку, чтение и анализ репозитория делают **субагенты**, а не основной агент — контекст основного агента не должен раздуваться простынями кода. См. [Agent Workflow](#agent-workflow-обязательные-правила-работы).
---
## 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
### 1. Code Style
@@ -27,13 +60,16 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
### 2. Database
- **Schema**: Defined in `db/init.sql` (runs on first container start)
- **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
- **Queries**: Parameterized only (`$1`, `$2`...), never string interpolation
- **Transactions**: Use `client.query('BEGIN')` / `COMMIT` / `ROLLBACK` for multi-statement ops
### 3. File Uploads
- **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`)
- **HEIC**: Auto-converted to JPEG via `heic-convert`
- **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`)
- **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
- **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)
- **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`)
@@ -58,7 +95,7 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
- **`resetTime` в `rateLimitStore.increment` обязан быть `Date`** — express-rate-limit v8 вызывает `resetTime.getTime()`
- **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату
- **Инвалидация — по префиксу** (`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>`, иначе деактивированный пользователь сохранит доступ
- **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1`
@@ -74,6 +111,17 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
- **Новое событие добавляется вместе с**: записью в `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`
### 4. API Patterns
- **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` и фильтр не добавляется
@@ -107,15 +155,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
### 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)
- Backup format: `data.json` (all tables) + `uploads/` directory
- Restore validates all data, resets sequences, sweeps orphans
- Формат архива: `tar.gz` с `data.json` + `uploads/`. Версия формата — `BACKUP_FORMAT_VERSION` в `backup-restore.js` (сейчас `2`), принимаются версии `1..2`; версия пишется в `data.json.version` и возвращается в ответе `POST /api/backup` и `POST /api/restore`
- `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
Каждый рецепт ниже начинается с субагента-разведки (см. [Agent Workflow](#agent-workflow-обязательные-правила-работы)): пусть он найдёт нужные места и вернёт `путь:строка`, а правки вносит основной агент.
### Add a new API endpoint
1. Add route in `server.js` (group with related routes)
2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public
@@ -185,6 +241,10 @@ node redis.selftest.js # unit + degradation, ne
node api.smoketest.js # e2e, needs running stack
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
# 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
# Audit diff checks
@@ -240,15 +300,17 @@ guards against.
| `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 |
| `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 |
| `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/migration.sql` | Idempotent migrations for existing DBs |
| `docker-compose.yml` | Service definitions (app, db, s3, tailscale) |
| `docker-compose.minio.yml` | Override: S3 service backed by MinIO instead of SeaweedFS |
| `Dockerfile` | App image build |
| `public/*.html` | Frontend pages |
| `public/admin.js` | Shared frontend logic |
| `public/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) |
| `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование |
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
| `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) |
@@ -275,6 +337,10 @@ guards against.
- ❌ Expose DB port (5432) outside docker network
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
- ❌ 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`)
---
+3 -2
View File
@@ -46,7 +46,8 @@
| ENT-5 | Trash view: list deleted entries, restore, permanent delete | 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-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)
| ID | Requirement | Priority |
@@ -175,7 +176,7 @@ branches
| GET | `/api/files` | Admin | All files (filters) |
| GET | `/api/files/detached` | Admin | Detached files |
| 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 |
| POST/PUT/DELETE | `/api/groups` | Admin | CRUD groups |
| GET/POST | `/api/groups/:id/photos` | Admin | Group photo chronicle |
+61 -5
View File
@@ -11,6 +11,7 @@
- **Воспитанники** — справочник с привязкой к группам
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
- **Отчёты о занятиях** — тьютор описывает, что прошли на занятии; галочка в окне отчёта отправляет текст модели, которая сверяет его с шаблоном делового сообщения (настраивается в «Настройках» → «Шаблон отчёта»): совпал — остаётся как есть, не совпал — переписывается в деловом виде. Обработка идёт в фоне, оригинал тьютора сохраняется, доступна история версий с восстановлением
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
@@ -121,30 +122,84 @@ REDIS_PASSWORD=случайная-длинная-строка
## ИИ-улучшение фото (photo-ai)
Сервис `photo-ai` (Real-ESRGAN) поднимается вместе со стеком и **включён по умолчанию**: `PHOTO_AI_URL`
в `docker-compose.yml` равен `http://photo-ai:8080`, кнопка «🤖 ИИ» активна, а задания обрабатывает
фоновый воркер. Работает на CPU, GPU не требуется.
Сервис `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`, `latency_ms`, `error`), `ai_configured` — задан ли `PHOTO_AI_URL`,
(`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
Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете.
@@ -473,7 +528,8 @@ node api.smoketest.js # сквозная проверка API (нужен
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям.
- **CORS отключён** — кросс-доменные запросы к API запрещены.
- **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/`.
- **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`.
- **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети).
+300 -46
View File
@@ -158,6 +158,10 @@
`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`
@@ -307,112 +311,360 @@ Stage 0 закрыт 2026-09-28, журнал проверок — «Журна
## 5. Stage 2. `photo-ai/Dockerfile` + compose
- [ ] `ARG TORCH_VARIANT=cpu` и `ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}`;
- [x] `ARG TORCH_VARIANT=cpu` и `ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}`;
один образ, `cu124` — вариант сборки.
- [ ] Сохранить патч `basicsr/data/degradations.py` (`functional_tensor` → `functional`) — без него basicsr
- [x] Сохранить патч `basicsr/data/degradations.py` (`functional_tensor` → `functional`) — без него basicsr
падает на torch ≥ 2.0. Не «упрощать» Dockerfile без проверки.
- [ ] Сохранить `libgl1 libglib2.0-0` (нужны facexlib), `numpy<2`, `opencv-python-headless`.
- [ ] Добавить `gfpgan`/`facexlib` (или вендоринг по `D4`), вендоренный CodeFormer копировать в образ
- [x] Сохранить `libgl1 libglib2.0-0` (нужны facexlib), `numpy<2`, `opencv-python-headless`.
- [x] Добавить `gfpgan`/`facexlib` (или вендоринг по `D4`), вендоренный CodeFormer копировать в образ
при наличии (`D5`).
- [ ] `ENV PHOTO_AI_MODELS_DIR=/models`; `MODEL_PATH` остаётся валидным алиасом.
- [ ] `docker-compose.yml`, сервис `photo-ai`: env `PHOTO_AI_DEVICE`, `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_TILE`,
- [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 — сохранить.
- [ ] Новый `docker-compose.gpu.yml` (по образцу `docker-compose.minio.yml`): `build.args.TORCH_VARIANT=cu124`,
- [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`).
Без него стек поднимается на любой машине.
- [ ] Сервису `app` добавить env `PHOTO_AI_FACE_MODEL` и `PHOTO_AI_FACE_TIMEOUT_MS`.
- [ ] **Приёмка:** CPU-сборка стартует; `/api/photo-jobs/status` показывает `service.device`;
- [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`
- [ ] `face=off` — только ESRGAN (текущее поведение).
- [ ] `face=face` — ESRGAN-фон + GFPGAN `paste_back`.
- [ ] `face=all` — ESRGAN(+`wdn` для `general-x4v3`) + мягкий денойз фона + лица.
- [ ] `strength` → `fidelity_weight` CodeFormer, `-w`; вне диапазона → `400`.
- [ ] Предупреждения (`warnings`: вход меньше 320×320, лиц не найдено, CodeFormer на CPU) в JSON-ответе.
- [ ] **Приёмка:** портрет 640×480 в `face` → `faces_found=1`, лицо резче; фото без лиц → `faces_found=0`
и результат не хуже `x2plus`; искусственный OOM (`PHOTO_AI_TILE=1024` на 4 ГБ) деградирует до CPU
- [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`)
- [ ] Таймауты из env: `PHOTO_AI_TIMEOUT_MS` (дефолт 300000), `PHOTO_AI_FACE_TIMEOUT_MS` (дефолт 600000)
- [x] Таймауты из env: `PHOTO_AI_TIMEOUT_MS` (дефолт 300000), `PHOTO_AI_FACE_TIMEOUT_MS` (дефолт 600000)
для `face != 'off'`. Таймаут выбирается на основе `params`, а не глобально.
- [ ] `runAiEnhance(srcKey, params)`: отправляет `image`, `scale`, `model`, `face`, `face_model`, `strength`
- [x] `runAiEnhance(srcKey, params)`: отправляет `image`, `scale`, `model`, `face`, `face_model`, `strength`
из `params` (дефолты при пустых `params`); ставит `Accept: application/json`; принимает и JSON
(base64 → буфер), и сырой `image/jpeg` (старый photo-ai) без ошибки.
- [ ] `503` / `Retry-After` / сетевая недоступность → `sleep` c экспоненциальной задержкой, `return false`,
- [x] `503` / `Retry-After` / сетевая недоступность → `sleep` c экспоненциальной задержкой, `return false`,
**`attempts` не инкрементится**; лимит мягких повторов (например 60) → одна честная ошибка
с понятным текстом (I5).
- [ ] `AbortError` от `AbortSignal.timeout` → сообщение «ИИ-сервис не ответил за N с» — это уже
- [x] `AbortError` от `AbortSignal.timeout` → сообщение «ИИ-сервис не ответил за N с» — это уже
«жёсткая» ошибка с попытками.
- [ ] `processOne`: `action` ∈ {`ai`, `ai_face`, `ai_upscale`} → AI-путь; `enhance` → sharp.
- [x] `processOne`: `action` ∈ {`ai`, `ai_face`, `ai_upscale`} → AI-путь; `enhance` → sharp.
Пустые `params` при `ai_face` → `face='face'`.
- [ ] `applyResult`: в `logAudit(..., 'photo.job.preview', {...})` добавить `model`, `face`, `face_model`,
- [x] `applyResult`: в `logAudit(..., 'photo.job.preview', {...})` добавить `model`, `face`, `face_model`,
`device`, `elapsed_ms`, `faces_found`, `warnings`. Текст уведомления `photo.job.done` — по факту
режима (`job.action === 'ai_face'` → «Фото обработано нейросетью с восстановлением лиц»).
- [ ] `CONFIG` воркера: + `face_model`, `default_model`, `face_timeout_ms` (попадают в
- [x] `CONFIG` воркера: + `face_model`, `default_model`, `face_timeout_ms` (попадают в
`GET /api/photo-jobs/status` → `worker.config`).
### 7.2 `server.js`
- [ ] `POST /api/entries/:id/photo/enhance-ai` (`server.js:5114`): принять `{model, face, face_model, strength}`;
- [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.
- [ ] `PHOTO_JOB_ACTIONS` (`server.js:2254`): + `'ai_face'`, `'ai_upscale'` (см. `D3`). Схема
- Валидация вынесена **перед** запросом записи: невалидное тело → `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)
- [ ] `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` photo-ai для оператора.
- [ ] `getStackInfo()` (`server.js:769`) — блок `photo_ai` (`engine: 'Real-ESRGAN + GFPGAN'`, `driver`,
- [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.
- [ ] Настройки (`PUT /api/settings`, рядом `server.js:1710`): `photo_ai_face_mode` ∈ `off|face|all` (дефолт `off`),
- `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/доков — фактическое устройство задаёт контейнер).
- [ ] Дефолты новых настроек: `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`),
- [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)
сохранить.
- [ ] `createPhotoEnhanceWorker({...})` (`server.js:6045`): + `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`,
- Строки добавлены и в `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`.
- [ ] `warnings` от photo-ai сохранять в аудит/уведомление, чтобы оператор видел, что face-режим не
- [x] `warnings` от photo-ai сохранять в аудит/уведомление, чтобы оператор видел, что face-режим не
сработал не из-за ошибки.
- [ ] **Приёмка:** задание с `face='face'` проходит `pending → processing → done`; `params` сохранены;
- [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. Фронтенд
- [ ] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима
- [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).
- [ ] `public/js/worker.js`: `PHOTO_ACTION_LABELS` + `ai_face: 'ИИ + лица'`, `ai_upscale: 'ИИ-апскейл'`;
- `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`.
- [ ] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект
- [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`).
- [ ] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`.
- [ ] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код).
- [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. Документация и тесты
@@ -441,11 +693,11 @@ Stage 0 закрыт 2026-09-28, журнал проверок — «Журна
| `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`, дефолт воркера/UI |
| `PHOTO_AI_FACE_MODEL` | `gfpgan` | `app.py`, дефолт воркера, дефолт `photo_ai_face_model` |
| `PHOTO_AI_LOAD_ALL` | `0` | `app.py` |
| `PHOTO_AI_FACE_MODE` | `off` | дефолт UI |
| `PHOTO_AI_FACE_MODE` | `off` | дефолт `photo_ai_face_mode` (UI) |
| `PHOTO_AI_JPEG_QUALITY` | `92` | `app.py` |
| `PHOTO_AI_TIMEOUT_MS` | `300000` | `worker.js` (сейчас хардкод) |
| `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` — первая пауза мягкого повтора |
@@ -456,9 +708,9 @@ Stage 0 закрыт 2026-09-28, журнал проверок — «Журна
## 11. Порядок и правила выполнения
- [ ] Этапы выполняются строго 0 → 1 → 2 → 3 → 4 → 5 → 6. Каждый этап закрывается своей приёмкой
**до** начала следующего.
- [ ] Каждый завершённый чекбокс отмечать (`- [x]`) в этом файле по мере выполнения.
- [x] Этапы выполняются строго 0 → 1 → 2 → 3 → 4 → 5 → 6. Каждый этап закрывается своей приёмкой
**до** начала следующего. (0–4 закрыты, см. журналы разделов)
- [x] Каждый завершённый чекбокс отмечать (`- [x]`) в этом файле по мере выполнения.
- [ ] Изменения в `AGENTS.md`/`.env.example`/`README.md` делать **в том же** изменении, что и код,
который они описывают (правило `AGENTS.md` про рассинхрон документации).
- [ ] Коммит на этап, а не на весь план: `app.py`+`Dockerfile`+compose → воркер/сервер → фронт → доки/тесты.
@@ -468,9 +720,11 @@ Stage 0 закрыт 2026-09-28, журнал проверок — «Журна
## 12. Готово, когда
- [ ] `photo-ai` стартует на CPU и на CUDA, устройство выбирается автоматически, `/health` показывает
фактическое устройство, имя GPU, VRAM, доступные и загруженные модели.
- [ ] Сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему.
- [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` не ломают приложение.
+43
View File
@@ -210,6 +210,49 @@ async function main() {
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' });
ok('logout', logout.status === 200, logout.status);
const afterLogout = await api('/api/auth/me', { token });
+488
View File
@@ -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,
};
+88
View File
@@ -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);
+79
View File
@@ -221,6 +221,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_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 (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
@@ -242,6 +273,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')
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 (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
@@ -300,3 +340,42 @@ 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;
+134
View File
@@ -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')
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 photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false;
@@ -295,3 +304,128 @@ INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true') ON CONFLICT (
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;
+32
View File
@@ -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]
+21 -2
View File
@@ -73,10 +73,15 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
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_PROMPT: ${AI_PROMPT:-}
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
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
TZ: Europe/Moscow
STORAGE_DRIVER: ${STORAGE_DRIVER:-local}
@@ -189,7 +194,9 @@ services:
- "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.
# Порт 8081 пробрасывается только на loopback хоста — наружу ничего не публикуется,
# хостовый 8080 уже занят text-corrector. Ручные проверки: curl http://127.0.0.1:8081/health
@@ -199,12 +206,24 @@ services:
restart: unless-stopped
environment:
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
volumes:
- 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:
+92
View File
@@ -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` | Скриншоты |
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+11 -4
View File
@@ -1,14 +1,19 @@
FROM python:3.10-slim
ARG TORCH_VARIANT=cpu
ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}
WORKDIR /app
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) || \
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"; \
fi
COPY app.py .
COPY app.py ./
COPY vendor/ ./vendor/
ENV PHOTO_AI_MODELS_DIR=/models
ENV MODEL_PATH=/models/RealESRGAN_x2plus.pth
VOLUME /models
+35 -4
View File
@@ -38,6 +38,7 @@ 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
@@ -233,19 +234,41 @@ class ModelNotReady(EnhanceError):
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):
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]
@@ -459,7 +482,7 @@ def build_esrgan(name, denoise):
scale=spec['scale'],
model_path=model_path,
dni_weight=dni_weight,
model=spec['arch'](),
model=guard_forward(spec['arch']()),
tile=state['tile'],
tile_pad=TILE_PAD,
pre_pad=PRE_PAD,
@@ -511,6 +534,7 @@ def build_face_gfpgan(spec, outscale, bg):
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,
@@ -532,6 +556,7 @@ def build_face_codeformer(spec, outscale, bg):
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):
@@ -612,7 +637,7 @@ def run_guarded(img, outscale, model_name, face, face_name, strength, warnings):
pool.set_tile(tile)
try:
return process_image(img, outscale, model_name, face, face_name, strength)
except RuntimeError as err:
except (RuntimeError, TileOOM) as err:
if not is_oom(err):
raise EnhanceError('ошибка модели: %s' % err, 500) from err
log.warning('нехватка памяти при tile=%s: %s', tile, err)
@@ -621,7 +646,7 @@ def run_guarded(img, outscale, model_name, face, face_name, strength, warnings):
degrade_to_cpu(warnings)
try:
return process_image(img, outscale, model_name, face, face_name, strength)
except RuntimeError as err:
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
@@ -819,6 +844,12 @@ async def enhance(request: Request,
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))
View File
+61
View File
@@ -161,6 +161,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.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: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: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}
@@ -215,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.open{display:flex}
.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 h3{font-size:1.1rem;font-weight:600}
.edit-modal label{font-size:.8rem;color:var(--muted);margin-bottom:4px}
@@ -642,4 +658,49 @@ body[data-page=settings] .page-head{align-items:flex-end}
.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}
+404
View File
@@ -49,6 +49,7 @@ function buildSidebar(active) {
const base = [
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
{ 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: 'groups', label: 'Группы', icon: 'users' },
@@ -176,6 +177,8 @@ const NOTIFY_ICONS = {
'backup.restore': 'database',
'backup.create': 'download',
'system.test': 'send',
'lesson.report': 'notebook-pen',
'lesson.ai.formatted': 'file-check',
};
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' };
@@ -483,6 +486,407 @@ function copyText(text) {
}
}
let lessonModalGroups = null;
let lessonModalGroupsPromise = null;
function lessonTodayIso() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function lessonNowTime() {
const d = new Date();
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
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) {
if (!el) return;
if (totalPages <= 1) { el.innerHTML = ''; return; }
+6
View File
@@ -28,6 +28,11 @@
<div class="recent" id="recentList"></div>
<div class="dash-link-row"><a href="journal.html">Все записи →</a></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">
<h3><span class="ic"><i data-lucide="camera"></i></span> Фото групп</h3>
<div class="photos-grid-dash" id="dashPhotos"></div>
@@ -40,6 +45,7 @@
<h3><span class="ic"><i data-lucide="zap"></i></span> Быстрые действия</h3>
<div class="actions-grid">
<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="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>
+64
View File
@@ -71,6 +71,70 @@
</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="admin.js"></script>
<script src="vendor/jszip.min.js"></script>
+1 -1
View File
@@ -150,7 +150,7 @@ nav a:hover{color:var(--text)}
<div class="field">
<label>Что сделал</label>
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
<textarea id="descInput" maxlength="300" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
</div>
<input type="text" name="website" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
+21
View File
@@ -27,6 +27,8 @@
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
.enhance-sliders .settings-field input[type=range] { width: 100%; }
.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; }
@media (max-width: 800px) {
.enhance-body { flex-direction: column; }
@@ -48,6 +50,7 @@
<div><label>По</label><input type="date" id="dateTo"></div>
<div><label>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</label>
<div class="view-toggle" id="viewToggle">
<button data-view="list" type="button">Список</button>
@@ -64,6 +67,17 @@
<img id="imgModalSrc">
</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="Закрыть">&times;</button>
</div>
<video id="videoPlayer" controls playsinline preload="metadata"></video>
</div>
</div>
<div class="modal-overlay" id="editModal">
<div class="edit-modal" style="max-width:720px">
<h3>Редактирование записи</h3>
@@ -224,6 +238,13 @@
<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="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="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
+20 -2
View File
@@ -26,10 +26,16 @@ const ACTION_LABELS = {
'student.update': 'Изменён ученик',
'student.delete': 'Удалён ученик',
'student.batch-group': 'Ученики перенесены в группу',
'student.profile.update': 'Изменён профиль ученика',
'student.photo.create': 'Загружено фото ученика',
'student.photo.delete': 'Удалено фото ученика',
'student.photo.main': 'Главное фото ученика',
'module.create': 'Создан модуль',
'module.update': 'Изменён модуль',
'module.delete': 'Модуль отключён',
'module.restore': 'Модуль включён',
'module.photo.create': 'Загружено фото модуля',
'module.photo.delete': 'Удалено фото модуля',
'entry.update': 'Изменена запись',
'entry.photo.update': 'Изменено фото записи',
'entry.photo.set_main': 'Главное фото записи',
@@ -47,6 +53,10 @@ const ACTION_LABELS = {
'photo-jobs.wake': 'Фото-воркер разбужен',
'photo-jobs.enabled': 'Переключена обработка фото',
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
'notifications.delete': 'Удалено уведомление',
'notifications.clear': 'Очищены уведомления',
'settings.logo.upload': 'Загружен логотип системы',
'settings.logo.remove': 'Удалён логотип системы',
'entry.soft-delete': 'Запись в корзину',
'entry.restore': 'Запись восстановлена',
'entry.permanent-delete': 'Запись удалена безвозвратно',
@@ -71,6 +81,14 @@ const ACTION_LABELS = {
'ai.profile.delete': 'Удалён профиль ИИ',
'ai.profile.activate': 'Выбран активный профиль ИИ',
'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;
@@ -191,7 +209,7 @@ function renderTargetDetails(target) {
<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; }
const pages = [];
const maxVisible = 5;
@@ -240,7 +258,7 @@ async function loadAudit() {
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
</tr>`).join('');
}
renderPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
renderAuditPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
}
function goAuditPage(p) {
+31
View File
@@ -100,6 +100,24 @@ function renderPhotos(rows) {
</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() {
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
@@ -110,11 +128,24 @@ async function loadDashboard() {
renderActive(d.active_groups);
renderTop(d.top_students);
renderPhotos(d.photos);
renderLessons(d.recent_lessons || []);
renderIcons();
}
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 () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
+102
View File
@@ -65,6 +65,7 @@ async function loadGroups() {
<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-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 class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
</div>
@@ -297,6 +298,101 @@ async function exportGroupUrls(groupId, groupName) {
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() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
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('addGroupBtn').addEventListener('click', addGroup);
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
@@ -307,6 +403,10 @@ document.getElementById('photoForm').addEventListener('submit', e => {
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('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('closePhotosBtn').addEventListener('click', closePhotos);
document.getElementById('closePhotoFormBtn').addEventListener('click', closePhotoForm);
@@ -320,6 +420,8 @@ document.getElementById('groupList').addEventListener('click', e => {
if (eg) { exportGroup(parseInt(eg.dataset.exportGroup, 10), eg.dataset.groupName); return; }
const eu = e.target.closest('[data-export-urls]');
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');
if (photoBtn) {
const id = parseInt(photoBtn.dataset.groupId, 10);
+19 -5
View File
@@ -120,8 +120,10 @@ function clearFiles() {
}
const MAX_FILES = 10;
const MAX_FILE_BYTES = 10 * 1024 * 1024;
const MAX_TOTAL_BYTES = 30 * 1024 * 1024;
const DEFAULT_FILE_LIMIT_MB = 50;
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 MIME_EXT = {
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp',
@@ -172,14 +174,25 @@ function totalUploadBytes() {
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) {
const added = [];
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) {
if (selectedFiles.length >= MAX_FILES) { skipped.push('Можно прикрепить не более 10 файлов'); break; }
if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше 10 МБ`); continue; }
if (selectedFiles.length >= MAX_FILES) { skipped.push(`Можно прикрепить не более ${MAX_FILES} файлов`); break; }
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 (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push('Суммарный размер файлов — не более 30 МБ'); break; }
if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push(`Суммарный размер файлов — не более ${totalLimitMb} МБ`); break; }
selectedFiles.push(file);
added.push(file.name);
}
@@ -319,6 +332,7 @@ async function resolveModuleId(name) {
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
const q = parseFloat(f.photo_capture_quality);
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') {
const camBtns = document.getElementById('camBtns');
if (camBtns) camBtns.style.display = 'none';
+129 -8
View File
@@ -34,6 +34,14 @@ function isImageFile(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) {
if (e.photo_path) return e.photo_path;
if (e.photos && e.photos.length) return e.photos[0].photo_path;
@@ -43,9 +51,16 @@ function mainPhotoPath(e) {
function filesHTML(files) {
if (!files || !files.length) return '';
return `<div class="entry-files">${files.map(f => {
const link = isImageFile(f.name)
? `<a class="f-link" href="#" data-img="${API}/api/files/${f.token}" title="Открыть">${esc(f.name)}</a>`
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`;
const url = `${API}/api/files/${f.token}`;
let link;
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>`;
}).join('')}</div>`;
}
@@ -498,6 +513,9 @@ let enhanceSaving = false;
let enhanceEngine = 'auto';
let enhanceEngineLoaded = false;
let photoAiEnabled = false;
let photoAiFaceMode = 'off';
let photoAiFaceModel = 'gfpgan';
let photoAiDevice = '';
let photoAiPollTimer = null;
let enhanceAiResultPath = null;
let enhanceAiJobId = null;
@@ -603,6 +621,46 @@ function 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() {
if (enhanceEngineLoaded) return;
enhanceEngineLoaded = true;
@@ -611,18 +669,31 @@ async function loadEnhanceEngine() {
const f = await r.json();
if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine;
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) {}
}
async function runPhotoAi() {
if (!enhanceEntryId || enhanceSaving) return;
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;
const btn = document.getElementById('enhanceAiBtn');
btn.disabled = true;
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) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка запуска ИИ');
@@ -630,12 +701,14 @@ async function runPhotoAi() {
const { jobId } = await res.json();
enhanceAiJobId = jobId;
showEnhanceResult(false);
setEnhanceLoadingText('ИИ обрабатывает фото... (может занять до минуты)');
setEnhanceLoadingText(mode.face === 'off'
? 'ИИ обрабатывает фото... (может занять до минуты)'
: 'ИИ обрабатывает фото и восстанавливает лица...');
btn.textContent = '⏳ ИИ...';
const result = await new Promise((resolve, reject) => {
photoAiPollTimer = setInterval(async () => {
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('Ошибка опроса статуса');
const s = await r.json();
if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); }
@@ -715,7 +788,10 @@ function openEnhance(id) {
orig.src = `${API}${mp}`;
document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none';
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');
setEnhanceClip(50);
@@ -1528,8 +1604,53 @@ document.getElementById('aiCorrectModal').addEventListener('click', e => {
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('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.getElementById('editCancelBtn').addEventListener('click', closeEdit);
document.getElementById('linkCreateCloseBtn').addEventListener('click', closeLinkCreate);
+187
View File
@@ -0,0 +1,187 @@
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 '';
const d = new Date(s + 'T00:00:00');
if (Number.isNaN(d.getTime())) return s;
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
function lessonWhenText(r) {
const date = lessonDateText(r.lesson_date);
const time = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
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();
})();
+12 -1
View File
@@ -3,6 +3,11 @@ let mPage = 1;
const M_PAGE_SIZE = 25;
let mSearchTimer = null;
let modulePhotoDeleteFlag = false;
let modulePhotoLimitMb = 50;
function modulePhotoHint() {
return `jpg, png, gif, webp, avif, heic и др., до ${modulePhotoLimitMb} МБ · или вставьте из буфера (Ctrl+V)`;
}
function mPlural(n, one, few, many) {
n = Math.abs(n) % 100; const n1 = n % 10;
@@ -68,7 +73,7 @@ function resetModulePhoto(m) {
preview.removeAttribute('src');
preview.style.display = 'none';
delBtn.style.display = 'none';
note.textContent = 'jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V)';
note.textContent = modulePhotoHint();
}
}
@@ -313,6 +318,12 @@ document.getElementById('moduleList').addEventListener('click', e => {
(async () => {
if (await requireAdminPage()) {
try {
const res = await fetch(`${API}/api/public-settings`);
const f = await res.json();
const n = parseInt(f.upload_file_limit_mb, 10);
if (Number.isFinite(n) && n >= 1) modulePhotoLimitMb = n;
} catch (e) { /* ignore */ }
buildSidebar(document.body.dataset.page);
loadModules();
}
+106 -9
View File
@@ -1,6 +1,6 @@
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
const NOTIFY_IDS = [];
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-bans'];
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
const SPAM_MAX = 10080;
const BAN_REASONS = {
'honeypot': 'Антиспам-поле',
@@ -12,6 +12,7 @@ const BAN_REASONS = {
let currentLogo = '';
let logoOriginal = '';
let currentSystemName = '';
let photoAiService = null;
function renderLogoPreview() {
const img = document.getElementById('logoPreviewImg');
@@ -205,6 +206,8 @@ async function loadSettings() {
document.getElementById('footerRight').value = s.footer_right || '';
document.getElementById('aiPrompt').value = s.ai_prompt || '';
document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false';
document.getElementById('lessonAiEnabled').checked = s.lesson_ai_enabled !== 'false';
document.getElementById('lessonAiPrompt').value = s.lesson_ai_prompt || '';
document.getElementById('shareShowMessage').checked = s.share_show_student_message === 'true';
document.getElementById('shareShowDate').checked = s.share_show_entry_date === 'true';
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
@@ -217,6 +220,12 @@ async function loadSettings() {
const pe = document.getElementById('photoEngine');
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
if (!pe.value) pe.value = 'auto';
const afm = document.getElementById('photoAiFaceMode');
afm.value = ['off', 'face', 'all'].includes(s.photo_ai_face_mode) ? s.photo_ai_face_mode : 'off';
const afm2 = document.getElementById('photoAiFaceModel');
afm2.value = ['gfpgan', 'codeformer'].includes(s.photo_ai_face_model) ? s.photo_ai_face_model : 'gfpgan';
const adp = document.getElementById('photoAiDevicePref');
adp.value = ['auto', 'cuda', 'cpu'].includes(s.photo_ai_device_pref) ? s.photo_ai_device_pref : 'auto';
document.getElementById('camEnabled').checked = s.camera_enabled !== 'false';
await renderNotifyTypes(s);
snapshotDirty();
@@ -225,6 +234,7 @@ async function loadSettings() {
loadAiProfiles();
renderStats(stRes);
loadSystemInfo();
loadPhotoAiStatus();
}
async function renderStats(res) {
@@ -369,6 +379,52 @@ function stackRow(label, value) {
return `<div class="sys-item"><span class="sys-label">${esc(label)}</span><span class="sys-value">${value}</span></div>`;
}
function photoAiDeviceMismatch(pref, device) {
const d = String(device || '');
if (pref === 'cuda') return !d.startsWith('cuda');
if (pref === 'cpu') return d !== 'cpu';
return false;
}
function renderPhotoAiStatus() {
const el = document.getElementById('photoAiStatus');
if (!el) return;
const svc = photoAiService;
if (!svc) { el.innerHTML = '<span class="hint">Статус фото-ИИ недоступен</span>'; return; }
const pref = document.getElementById('photoAiDevicePref').value;
const device = svc.device || '—';
const mismatch = photoAiDeviceMismatch(pref, svc.device);
const color = svc.reachable === false ? '#ef4444' : mismatch ? '#f59e0b' : '#16a34a';
const chips = [];
chips.push(`<span class="chip" style="color:${color}">фактическое: <b>${esc(device)}</b></span>`);
if (svc.device_name) chips.push(`<span class="chip">${esc(String(svc.device_name))}</span>`);
if (svc.vram_total_mb) chips.push(`<span class="chip">VRAM: <b>${esc(String(svc.vram_free_mb ?? '—'))}</b> свободно из ${esc(String(svc.vram_total_mb))} МБ</span>`);
chips.push(`<span class="chip">Модели: <b>${esc((svc.models || []).join(', ') || '—')}</b></span>`);
chips.push(`<span class="chip">Модели лиц: <b>${esc((svc.face_models || []).join(', ') || '—')}</b></span>`);
if (svc.loaded && svc.loaded.length) chips.push(`<span class="chip">Загружено: <b>${esc(svc.loaded.join(', '))}</b></span>`);
if (!svc.configured) chips.push('<span class="chip" style="color:#ef4444">PHOTO_AI_URL не настроен — ИИ-фото отключено</span>');
else if (svc.reachable === false) chips.push(`<span class="chip" style="color:#ef4444">сервис недоступен${svc.error ? `: ${esc(String(svc.error))}` : ''}</span>`);
else if (svc.ready === false) chips.push('<span class="chip" style="color:#f59e0b">модели загружаются</span>');
if (mismatch) chips.push('<span class="chip" style="color:#f59e0b">желаемое устройство не совпадает с фактическим — задаётся переменной PHOTO_AI_DEVICE в контейнере</span>');
el.innerHTML = chips.join('');
}
async function loadPhotoAiStatus(btn) {
const el = document.getElementById('photoAiStatus');
if (!el) return;
if (btn) btn.classList.add('spinning');
try {
const res = await fetch(`${API}/api/photo-jobs/status?recent_limit=1`, { headers: hdr() });
if (!res.ok) throw new Error('status');
photoAiService = (await res.json()).service || null;
renderPhotoAiStatus();
} catch {
el.innerHTML = '<span class="hint">Не удалось загрузить статус фото-ИИ</span>';
} finally {
if (btn) btn.classList.remove('spinning');
}
}
function renderStackInfo(stack) {
const el = document.getElementById('stackInfo');
if (!el) return;
@@ -419,6 +475,16 @@ function renderStackInfo(stack) {
? stackRow('Бакет', esc(`${st.bucket || '—'}${st.region ? ` · ${st.region}` : ''}`))
: stackRow('Каталог', esc(st.dir || '—'));
const ai = stack.photo_ai || {};
const aiState = !ai.configured
? stackState('warn', 'не настроен')
: ai.reachable
? stackState(ai.ready === false ? 'warn' : 'on', ai.ready === false ? 'модели грузятся' : 'отвечает')
: stackState('err', 'нет связи');
const aiVram = ai.vram_total_mb
? `${ai.vram_free_mb ?? '—'} свободно из ${ai.vram_total_mb} МБ`
: null;
el.innerHTML = `
<div class="sys-card">
<div class="sys-title">Приложение</div>
@@ -473,6 +539,20 @@ function renderStackInfo(stack) {
${stackRow('Локальный кеш', s3 ? esc(st.local_fallback ? 'включён' : 'выключен') : esc('—'))}
${s3 ? stackRow('Локальные копии', esc(st.keep_local ? 'хранятся' : 'удаляются')) : ''}
</div>
<div class="sys-card">
<div class="sys-title">Фото-ИИ</div>
${stackRow('Состояние', aiState)}
${stackRow('Движок', esc(ai.engine || 'Real-ESRGAN + GFPGAN'))}
${stackRow('Адрес', esc(ai.host || '—'))}
${stackRow('Устройство', esc(`${ai.device || '—'}${ai.device_name ? ` · ${ai.device_name}` : ''}`))}
${ai.half === null || ai.half === undefined ? '' : stackRow('Режим', esc(ai.half ? 'fp16 (half)' : 'fp32'))}
${aiVram ? stackRow('VRAM', esc(aiVram)) : ''}
${stackRow('Модели', esc((ai.models || []).join(', ') || '—'))}
${stackRow('Модели лиц', esc((ai.face_models || []).join(', ') || '—'))}
${stackRow('Загружено', esc((ai.loaded || []).join(', ') || '—'))}
${ai.error ? `<div class="sys-meta" style="color:#ef4444">${esc(String(ai.error))}</div>` : ''}
</div>
`;
}
@@ -729,6 +809,8 @@ async function saveSettings() {
footer_right: document.getElementById('footerRight').value.trim(),
ai_prompt: document.getElementById('aiPrompt').value.trim(),
ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false',
lesson_ai_enabled: document.getElementById('lessonAiEnabled').checked ? 'true' : 'false',
lesson_ai_prompt: document.getElementById('lessonAiPrompt').value.trim(),
share_show_student_message: document.getElementById('shareShowMessage').checked ? 'true' : 'false',
share_show_entry_date: document.getElementById('shareShowDate').checked ? 'true' : 'false',
share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false',
@@ -737,6 +819,9 @@ async function saveSettings() {
photo_capture_resolution: document.getElementById('photoResolution').value,
photo_capture_quality: String(document.getElementById('photoQuality').value),
photo_enhance_engine: document.getElementById('photoEngine').value,
photo_ai_face_mode: document.getElementById('photoAiFaceMode').value,
photo_ai_face_model: document.getElementById('photoAiFaceModel').value,
photo_ai_device_pref: document.getElementById('photoAiDevicePref').value,
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
trash_purge_days: String(trashDays),
...notifySettingsPayload(notifyRetentionDays),
@@ -778,11 +863,19 @@ async function downloadBackup() {
showError('Ошибка формирования бэкапа: ' + (data.error || res.status));
return;
}
const head = await fetch(data.url, { method: 'HEAD' }).catch(() => null);
if (head && !head.ok) {
if (st) { st.classList.add('err'); st.textContent = 'Ссылка на архив устарела, попробуйте ещё раз'; }
showError('Ошибка скачивания бэкапа');
return;
const size = formatBackupSize(data.size);
if (st) {
st.classList.remove('err');
st.innerHTML = '';
const link = document.createElement('a');
link.href = data.url;
link.download = data.filename || 'whatido-backup.tar.gz';
link.textContent = data.filename || 'whatido-backup.tar.gz';
link.style.color = 'var(--accent)';
st.appendChild(link);
const note = document.createElement('span');
note.textContent = `${size ? ` (${size})` : ''} — ссылка активна 30 минут, скачивание можно повторить`;
st.appendChild(note);
}
const a = document.createElement('a');
a.href = data.url;
@@ -790,8 +883,6 @@ async function downloadBackup() {
document.body.appendChild(a);
a.click();
a.remove();
const size = formatBackupSize(data.size);
if (st) st.textContent = `Архив ${data.filename}${size ? ` (${size})` : ''} — скачивается браузером`;
showToast('Бэкап сформирован' + (size ? ': ' + size : ''));
} catch (err) {
if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап: ' + err.message; }
@@ -888,6 +979,12 @@ if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', () => loadSyste
const stackRefreshBtn = document.getElementById('stackRefreshBtn');
if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(stackRefreshBtn));
const photoAiRefreshBtn = document.getElementById('photoAiRefreshBtn');
if (photoAiRefreshBtn) photoAiRefreshBtn.addEventListener('click', () => loadPhotoAiStatus(photoAiRefreshBtn));
const photoAiDevicePref = document.getElementById('photoAiDevicePref');
if (photoAiDevicePref) photoAiDevicePref.addEventListener('change', () => renderPhotoAiStatus());
const notifyTestBtn = document.getElementById('notifyTestBtn');
if (notifyTestBtn) notifyTestBtn.addEventListener('click', async () => {
notifyTestBtn.classList.add('spinning');
+69 -1
View File
@@ -30,6 +30,53 @@ async function loadStudents() {
renderStudents();
}
function studentInitials(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!parts.length) return 'У';
const a = parts[0].charAt(0);
const b = parts.length > 1 ? parts[1].charAt(0) : (parts[0].length > 1 ? parts[0].charAt(1) : '');
return (a + b).toUpperCase();
}
function photoFullSrc(p) {
if (!p) return '';
return p.startsWith('/') ? `${API}${p}` : p;
}
function setStudentAvatar(el, path, name) {
if (!el) return;
if (path) {
el.classList.remove('empty');
el.dataset.studentPhoto = path;
el.title = 'Увеличить фото';
el.innerHTML = `<img src="${esc(thumbSrc(path))}" alt="" loading="lazy">`;
const img = el.querySelector('img');
if (img) img.addEventListener('error', () => setStudentAvatar(el, '', name), { once: true });
} else {
el.classList.add('empty');
delete el.dataset.studentPhoto;
el.title = 'Фото не загружено — нажмите, чтобы загрузить';
el.innerHTML = `<span>${esc(studentInitials(name))}</span>`;
}
}
async function syncStudentAvatar(id, photoPath) {
const el = document.getElementById(`savatar-${id}`);
if (!el) return;
const nameInput = document.getElementById(`sname-${id}`);
const name = nameInput ? nameInput.value : '';
if (photoPath !== undefined) {
setStudentAvatar(el, photoPath || '', name);
return;
}
try {
const res = await fetch(`${API}/api/students/${id}/photos`, { headers: hdr() });
if (!res.ok) return;
const data = await res.json();
setStudentAvatar(el, data.photo_path || '', name);
} catch (err) {}
}
function renderStudents() {
const q = document.getElementById('studentSearch').value.trim().toLowerCase();
const gid = document.getElementById('studentGroupFilter').value;
@@ -43,7 +90,10 @@ function renderStudents() {
else {
list.innerHTML = slice.map(s => `
<div class="item">
<div class="student-main">
<button type="button" class="student-avatar" id="savatar-${s.id}" data-avatar-student="${s.id}"></button>
<input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}">
</div>
<select class="group-select" id="sgroup-${s.id}"></select>
<div class="student-actions">
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
@@ -55,7 +105,10 @@ function renderStudents() {
</div>
</div>
`).join('');
slice.forEach(s => loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id));
slice.forEach(s => {
setStudentAvatar(document.getElementById(`savatar-${s.id}`), s.photo_path, s.name);
loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id);
});
}
renderPager(document.getElementById('pager'), sPage, totalPages, goStudentPage);
}
@@ -244,6 +297,7 @@ async function uploadStudentPhoto(file) {
const res = await fetch(`${API}/api/students/${profileDataId}/photos`, { method: 'POST', headers: hdr(), body: fd });
if (res.ok) {
await loadStudentPhotos(profileDataId);
syncStudentAvatar(profileDataId);
showToast('Фото загружено');
} else {
const e = await res.json().catch(() => ({}));
@@ -262,6 +316,7 @@ async function setMainStudentPhoto(pid) {
const r = await res.json();
document.getElementById('pfPhoto').value = r.photo_path || '';
renderStudentPhotos();
syncStudentAvatar(profileDataId, r.photo_path || '');
showToast('Главное фото обновлено');
} else {
const e = await res.json().catch(() => ({}));
@@ -279,6 +334,7 @@ async function delStudentPhoto(pid) {
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
await loadStudentPhotos(profileDataId);
syncStudentAvatar(profileDataId);
showToast('Фото удалено');
} else {
const e = await res.json().catch(() => ({}));
@@ -522,7 +578,19 @@ document.getElementById('attachModal').addEventListener('click', e => { if (e.ta
document.getElementById('batchCancelBtn').addEventListener('click', closeBatchAdd);
document.getElementById('attachCancelBtn').addEventListener('click', closeBatchAttach);
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
document.addEventListener('keydown', e => {
if (e.key === 'Escape') document.getElementById('imgModal').classList.remove('open');
});
document.getElementById('studentList').addEventListener('click', e => {
const av = e.target.closest('[data-avatar-student]');
if (av) {
const p = av.dataset.studentPhoto;
if (p) showImg(photoFullSrc(p));
else openProfileData(parseInt(av.dataset.avatarStudent, 10));
return;
}
const sv = e.target.closest('[data-save-student]');
if (sv) { saveStudent(parseInt(sv.dataset.saveStudent, 10)); return; }
const cp = e.target.closest('[data-copy-student]');
+52 -3
View File
@@ -19,6 +19,7 @@ const STATUS_BADGE = {
let state = null;
let photoState = null;
let photoJobRow = null;
let photoJobMeta = {};
let refreshTimer = null;
let busy = false;
let photoBusy = false;
@@ -29,6 +30,8 @@ let photoTotal = 0;
const PHOTO_ACTION_LABELS = {
ai: 'ИИ-обработка',
ai_face: 'ИИ + лица',
ai_upscale: 'ИИ-апскейл',
enhance: 'Улучшение',
restore: 'Возврат оригинала',
rollback: 'Откат',
@@ -224,9 +227,15 @@ function render() {
function renderPhoto() {
if (!photoState) return;
const w = photoState.worker || {};
document.getElementById('photoStateHint').textContent = !photoState.enabled
? 'Обработка фотографий выключена'
: (!photoState.ai_configured ? 'PHOTO_AI_URL не настроен' : (w.processing ? `Обрабатывается задание #${w.current_id ?? ''}` : 'Ожидание очереди'));
const svc = photoState.service || {};
let hint;
if (!photoState.enabled) hint = 'Обработка фотографий выключена';
else if (!photoState.ai_configured) hint = 'PHOTO_AI_URL не настроен';
else if (svc.reachable === false) hint = 'Сервис фото-ИИ недоступен — задания ждут';
else if (w.processing) hint = `Обрабатывается задание #${w.current_id ?? ''}`;
else hint = 'Ожидание очереди';
if (photoState.enabled && photoState.ai_configured && svc.reachable && svc.device) hint += ` · расчёт на ${svc.device}`;
document.getElementById('photoStateHint').textContent = hint;
document.getElementById('photoToggleBtn').textContent = photoState.enabled ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер';
const c = photoState.counts || {};
document.getElementById('photoCounts').innerHTML = [
@@ -297,12 +306,52 @@ function setPhotoJobClip(p) {
document.getElementById('photoJobDivider').style.left = v + '%';
}
async function loadPhotoJobMeta() {
try {
const res = await fetch(`${API}/api/audit?action=photo.job.preview&limit=200`, { headers: hdr() });
if (!res.ok) return;
const rows = await res.json();
const map = {};
rows.forEach(r => {
const t = r.target;
if (t && t.job_id && !map[t.job_id]) map[t.job_id] = t;
});
photoJobMeta = map;
} catch {
}
}
function renderPhotoJobMeta(r) {
const el = document.getElementById('photoJobMeta');
if (!el) return;
const p = r.params && typeof r.params === 'object' ? r.params : {};
const m = photoJobMeta[r.id] || {};
const chips = [];
const model = m.model || p.model;
if (model) chips.push(['модель', esc(String(model))]);
const face = m.face || p.face;
if (face && face !== 'off') {
const fm = m.face_model || p.face_model;
const title = face === 'all' ? 'лица + фон' : 'только лица';
chips.push(['режим', esc(title) + (fm ? ` (${esc(String(fm))})` : '')]);
}
if (m.faces_found !== null && m.faces_found !== undefined) chips.push(['найдено лиц', String(m.faces_found)]);
if (m.device) chips.push(['устройство', esc(String(m.device))]);
if (m.elapsed_ms !== null && m.elapsed_ms !== undefined) chips.push(['обработка', esc(fmtDur(m.elapsed_ms))]);
if (Array.isArray(m.warnings) && m.warnings.length) chips.push(['предупреждения', esc(m.warnings.join('; '))]);
el.innerHTML = chips.length
? chips.map(([k, v]) => `<span class="chip">${esc(k)}: <b>${v}</b></span>`).join('')
: '<span class="hint">Метаданные обработки недоступны</span>';
}
function openPhotoJob(r) {
photoJobRow = r;
document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`;
document.getElementById('photoJobBefore').src = thumbUrl(r.before_path);
document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : '';
document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none';
renderPhotoJobMeta(r);
loadPhotoJobMeta().then(() => { if (photoJobRow === r) renderPhotoJobMeta(r); });
setPhotoJobClip(50);
document.getElementById('photoJobModal').classList.add('open');
}
+32
View File
@@ -0,0 +1,32 @@
<title>Отчёты о занятиях — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="lessons">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>Отчёты о занятиях</h2>
<p class="page-sub">Что прошли на занятиях — тьютор заполняет в конце занятия</p>
</div>
<div class="filters">
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Текст отчёта..."></div>
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
<div><label>С</label><input type="date" id="dateFrom"></div>
<div><label>По</label><input type="date" id="dateTo"></div>
<div><label>&nbsp;</label><button type="button" class="btn-primary" id="addLessonBtn"><i data-lucide="notebook-pen"></i> Что прошли на занятии</button></div>
</div>
<div class="hint" id="lessonsState" style="margin-bottom:12px"></div>
<div class="lesson-list" id="lessonsList"><div class="empty">Загрузка…</div></div>
<div class="pager" id="pager"></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="js/lessons.js"></script>
</body>
</html>
+1 -1
View File
@@ -52,7 +52,7 @@
<input type="file" id="modulePhotoFile" accept="image/*" style="display:none">
<button type="button" id="modulePhotoDeleteBtn" class="btn-link danger" style="display:none">Удалить</button>
</div>
<span id="modulePhotoNote" class="hint">jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V)</span>
<span id="modulePhotoNote" class="hint">jpg, png, gif, webp, avif, heic и др. · или вставьте из буфера (Ctrl+V)</span>
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
+58
View File
@@ -26,8 +26,10 @@
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
<a href="#sec-photo-ai" data-nav="sec-photo-ai"><i data-lucide="sparkles"></i> Фото-ИИ</a>
<a href="#sec-notify" data-nav="sec-notify"><i data-lucide="bell"></i> Уведомления</a>
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
<a href="#sec-lesson-ai" data-nav="sec-lesson-ai"><i data-lucide="notebook-pen"></i> Шаблон отчёта</a>
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
<a href="#sec-trash" data-nav="sec-trash"><i data-lucide="trash-2"></i> Корзина</a>
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
@@ -164,6 +166,24 @@
<div class="ai-queue" id="aiQueueInfo"></div>
</div>
<div class="settings-card" id="sec-lesson-ai">
<div class="card-head">
<div class="ic"><i data-lucide="notebook-pen"></i></div>
<h3>Шаблон отчёта о занятии</h3>
</div>
<p class="hint">Инструкция для нейросети: она переписывает текст отчёта тьютора в деловой стиль — связный текст от третьего лица, который начинается со слов «На занятии ребята …». Если текст уже написан нормально, он остаётся без изменений. Оригинал тьютора всегда сохраняется, результат можно посмотреть в истории версий.</p>
<label class="toggle">
<input type="checkbox" id="lessonAiEnabled">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Проверять отчёты по шаблону</b><small>Применяется, когда тьютор включил галочку в окне отчёта о занятии</small></span>
</label>
<div class="settings-field">
<label for="lessonAiPrompt">Шаблон и правила</label>
<textarea id="lessonAiPrompt" class="settings-input mono" rows="12" spellcheck="false"></textarea>
<span class="hint">Пусто — будет подставлен шаблон по умолчанию</span>
</div>
</div>
<div class="settings-card">
<div class="card-head">
<div class="ic"><i data-lucide="boxes"></i></div>
@@ -259,6 +279,44 @@
</div>
</div>
<div class="settings-card" id="sec-photo-ai">
<div class="card-head">
<div class="ic"><i data-lucide="sparkles"></i></div>
<h3>Фото-ИИ</h3>
<button class="icon-btn" id="photoAiRefreshBtn" type="button" title="Обновить статус фото-ИИ" aria-label="Обновить статус фото-ИИ"><i data-lucide="refresh-cw"></i></button>
</div>
<p class="hint">Нейросетевая обработка фотографий: Real-ESRGAN увеличивает разрешение, GFPGAN восстанавливает лица. Лицевой режим включается только явно — модель дорисовывает лицо по приору и может изменить черты. Без `PHOTO_AI_URL` кнопка «🤖 ИИ» в журнале скрыта.</p>
<div class="settings-row">
<div class="settings-field">
<label for="photoAiFaceMode">Режим по умолчанию</label>
<select id="photoAiFaceMode" class="settings-input">
<option value="off">Без лиц — только увеличение</option>
<option value="face">Лица — восстановить лица, фон увеличить</option>
<option value="all">Лица + фон — восстановить лица и снять шум фона</option>
</select>
<span class="hint">Предлагается в окне улучшения фото, режим можно переключить там же</span>
</div>
<div class="settings-field">
<label for="photoAiFaceModel">Модель лиц</label>
<select id="photoAiFaceModel" class="settings-input">
<option value="gfpgan">GFPGAN v1.4</option>
<option value="codeformer">CodeFormer</option>
</select>
<span class="hint">CodeFormer работает, только если модуль установлен в образ photo-ai</span>
</div>
<div class="settings-field">
<label for="photoAiDevicePref">Желаемое устройство</label>
<select id="photoAiDevicePref" class="settings-input">
<option value="auto">Авто (GPU, если доступен)</option>
<option value="cuda">GPU (CUDA)</option>
<option value="cpu">CPU</option>
</select>
<span class="hint">Документирующее значение: фактическое устройство задаёт переменная PHOTO_AI_DEVICE в контейнере</span>
</div>
</div>
<div class="data-stats" id="photoAiStatus"><span class="hint">Загрузка статуса фото-ИИ…</span></div>
</div>
<div class="settings-card" id="sec-trash">
<div class="card-head">
<div class="ic"><i data-lucide="trash-2"></i></div>
+4
View File
@@ -209,6 +209,10 @@
</div>
</div>
<div class="modal-overlay" id="imgModal">
<img id="imgModalSrc" alt="">
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
+1
View File
@@ -178,6 +178,7 @@
<div class="enhance-badge left">Было</div>
<div class="enhance-badge right">Стало</div>
</div>
<div class="data-stats" id="photoJobMeta" style="margin-top:12px"></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:14px">
<button type="button" class="btn-primary ghost" id="photoJobCloseBtn">Закрыть</button>
<button type="button" class="btn-primary" id="photoJobRollbackBtn">↩ Откатить к версии «Было»</button>
+1047 -403
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More