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.
This commit is contained in:
dev
2026-09-30 11:15:54 +03:00
parent 884188e9ec
commit 45033bffa8
8 changed files with 311 additions and 15 deletions
+45 -5
View File
@@ -608,23 +608,63 @@ nvidia.com/gpu=all`) — так демон перезапускать не ну
## 8. Stage 5. Фронтенд ## 8. Stage 5. Фронтенд
- [ ] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима - [x] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима
(«Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон»); значение уходит в («Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон»); значение уходит в
`POST .../enhance-ai` телом `{model, face, face_model}`. Дефолт — из `photo_ai_face_mode` `POST .../enhance-ai` телом `{model, face, face_model}`. Дефолт — из `photo_ai_face_mode`
(`/api/public-settings`, `loadEnhanceEngine`); при `device === 'cpu'` — подсказка «на CPU медленно». (`/api/public-settings`, `loadEnhanceEngine`); при `device === 'cpu'` — подсказка «на CPU медленно».
Кнопка по-прежнему скрыта при `photo_ai_enabled === 'false'` (I3). Кнопка по-прежнему скрыта при `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); в модалке сравнения — `model`, `device`, `faces_found`, `elapsed_ms` (данные из `params`/аудита, I6);
строка состояния учитывает `service.reachable === false` и `service.device`. строка состояния учитывает `service.reachable === false` и `service.device`.
- [ ] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект - [x] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект
face-модели, селект желаемого устройства + строка «фактическое: …» с подсветкой расхождения; face-модели, селект желаемого устройства + строка «фактическое: …» с подсветкой расхождения;
read-only статус (`device_name`, VRAM, список моделей) из `/api/photo-jobs/status`. read-only статус (`device_name`, VRAM, список моделей) из `/api/photo-jobs/status`.
Новые id внести в `DIRTY_FIELDS` (`settings.js:1`) и в payload (`settings.js:739`). Новые id внести в `DIRTY_FIELDS` (`settings.js:1`) и в payload (`settings.js:739`).
- [ ] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`. - [x] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`.
- [ ] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код). - [x] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код).
- [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение - [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение
«Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и VRAM. «Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и 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. Документация и тесты ## 9. Stage 6. Документация и тесты
+9
View File
@@ -27,6 +27,8 @@
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; } .enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
.enhance-sliders .settings-field input[type=range] { width: 100%; } .enhance-sliders .settings-field input[type=range] { width: 100%; }
.enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; } .enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; }
.enhance-ai-mode { width: auto; min-width: 200px; padding: 9px 12px; font-size: .85rem; }
.enhance-ai-hint { font-size: .75rem; color: var(--muted); max-width: 340px; }
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; } .photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
@media (max-width: 800px) { @media (max-width: 800px) {
.enhance-body { flex-direction: column; } .enhance-body { flex-direction: column; }
@@ -224,6 +226,13 @@
<div class="card-foot enhance-foot"> <div class="card-foot enhance-foot">
<button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button> <button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button>
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button> <button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
<select id="enhanceAiMode" class="settings-input enhance-ai-mode" style="display:none" title="Режим ИИ-обработки фото">
<option value="x2">Универсально (x2)</option>
<option value="x4">Быстро (x4)</option>
<option value="faces">Лица (GFPGAN)</option>
<option value="facesbg">Лица + фон</option>
</select>
<span class="enhance-ai-hint" id="enhanceAiHint" style="display:none"></span>
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button> <button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button> <button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button> <button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
+10
View File
@@ -26,10 +26,16 @@ const ACTION_LABELS = {
'student.update': 'Изменён ученик', 'student.update': 'Изменён ученик',
'student.delete': 'Удалён ученик', 'student.delete': 'Удалён ученик',
'student.batch-group': 'Ученики перенесены в группу', 'student.batch-group': 'Ученики перенесены в группу',
'student.profile.update': 'Изменён профиль ученика',
'student.photo.create': 'Загружено фото ученика',
'student.photo.delete': 'Удалено фото ученика',
'student.photo.main': 'Главное фото ученика',
'module.create': 'Создан модуль', 'module.create': 'Создан модуль',
'module.update': 'Изменён модуль', 'module.update': 'Изменён модуль',
'module.delete': 'Модуль отключён', 'module.delete': 'Модуль отключён',
'module.restore': 'Модуль включён', 'module.restore': 'Модуль включён',
'module.photo.create': 'Загружено фото модуля',
'module.photo.delete': 'Удалено фото модуля',
'entry.update': 'Изменена запись', 'entry.update': 'Изменена запись',
'entry.photo.update': 'Изменено фото записи', 'entry.photo.update': 'Изменено фото записи',
'entry.photo.set_main': 'Главное фото записи', 'entry.photo.set_main': 'Главное фото записи',
@@ -47,6 +53,10 @@ const ACTION_LABELS = {
'photo-jobs.wake': 'Фото-воркер разбужен', 'photo-jobs.wake': 'Фото-воркер разбужен',
'photo-jobs.enabled': 'Переключена обработка фото', 'photo-jobs.enabled': 'Переключена обработка фото',
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь', 'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
'notifications.delete': 'Удалено уведомление',
'notifications.clear': 'Очищены уведомления',
'settings.logo.upload': 'Загружен логотип системы',
'settings.logo.remove': 'Удалён логотип системы',
'entry.soft-delete': 'Запись в корзину', 'entry.soft-delete': 'Запись в корзину',
'entry.restore': 'Запись восстановлена', 'entry.restore': 'Запись восстановлена',
'entry.permanent-delete': 'Запись удалена безвозвратно', 'entry.permanent-delete': 'Запись удалена безвозвратно',
+66 -5
View File
@@ -498,6 +498,9 @@ let enhanceSaving = false;
let enhanceEngine = 'auto'; let enhanceEngine = 'auto';
let enhanceEngineLoaded = false; let enhanceEngineLoaded = false;
let photoAiEnabled = false; let photoAiEnabled = false;
let photoAiFaceMode = 'off';
let photoAiFaceModel = 'gfpgan';
let photoAiDevice = '';
let photoAiPollTimer = null; let photoAiPollTimer = null;
let enhanceAiResultPath = null; let enhanceAiResultPath = null;
let enhanceAiJobId = null; let enhanceAiJobId = null;
@@ -603,6 +606,46 @@ function resetEnhanceZoom() {
area.addEventListener('dblclick', resetEnhanceZoom); area.addEventListener('dblclick', resetEnhanceZoom);
})(); })();
const PHOTO_AI_MODES = {
x2: { model: 'x2plus', face: 'off', face_model: null, title: 'Универсально (x2)' },
x4: { model: 'general-x4v3', face: 'off', face_model: null, title: 'Быстро (x4)' },
faces: { model: 'x2plus', face: 'face', face_model: null, title: 'Лица (восстановление)' },
facesbg: { model: 'x2plus', face: 'all', face_model: null, title: 'Лица + фон' },
};
function photoAiMode() {
const sel = document.getElementById('enhanceAiMode');
const key = sel && PHOTO_AI_MODES[sel.value] ? sel.value : 'x2';
const m = { ...PHOTO_AI_MODES[key] };
m.face_model = m.face === 'off' ? null : photoAiFaceModel;
return m;
}
function defaultPhotoAiModeKey() {
if (photoAiFaceMode === 'all') return 'facesbg';
if (photoAiFaceMode === 'face') return 'faces';
return 'x2';
}
function updateAiHint() {
const hint = document.getElementById('enhanceAiHint');
if (!hint) return;
const cpu = photoAiDevice.startsWith('cpu');
hint.textContent = cpu ? 'Фото-ИИ считает на CPU — лицевые режимы могут занять несколько минут' : '';
hint.style.display = photoAiEnabled && cpu ? '' : 'none';
}
async function loadPhotoAiDevice() {
if (!isAdmin()) return;
try {
const r = await fetch(`${API}/api/photo-ai/health`, { headers: hdr() });
if (!r.ok) return;
const h = await r.json();
if (h && h.device) photoAiDevice = String(h.device);
updateAiHint();
} catch (e) {}
}
async function loadEnhanceEngine() { async function loadEnhanceEngine() {
if (enhanceEngineLoaded) return; if (enhanceEngineLoaded) return;
enhanceEngineLoaded = true; enhanceEngineLoaded = true;
@@ -611,18 +654,31 @@ async function loadEnhanceEngine() {
const f = await r.json(); const f = await r.json();
if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine; if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine;
photoAiEnabled = f.photo_ai_enabled === 'true'; photoAiEnabled = f.photo_ai_enabled === 'true';
if (['off', 'face', 'all'].includes(f.photo_ai_face_mode)) photoAiFaceMode = f.photo_ai_face_mode;
if (['gfpgan', 'codeformer'].includes(f.photo_ai_face_model)) photoAiFaceModel = f.photo_ai_face_model;
const sel = document.getElementById('enhanceAiMode');
if (sel) sel.value = defaultPhotoAiModeKey();
loadPhotoAiDevice();
} catch (e) {} } catch (e) {}
} }
async function runPhotoAi() { async function runPhotoAi() {
if (!enhanceEntryId || enhanceSaving) return; if (!enhanceEntryId || enhanceSaving) return;
const entryId = enhanceEntryId; const entryId = enhanceEntryId;
if (!confirm('ИИ-восстановление фото (Real-ESRGAN). Может занять до минуты на CPU. Продолжить?')) return; const mode = photoAiMode();
const faceNote = mode.face === 'off'
? 'Может занять до минуты на CPU.'
: `Восстановление лиц (${mode.face_model}), на CPU может занять несколько минут.`;
if (!confirm(`ИИ-обработка фото: ${mode.title}. ${faceNote} Продолжить?`)) return;
enhanceSaving = true; enhanceSaving = true;
const btn = document.getElementById('enhanceAiBtn'); const btn = document.getElementById('enhanceAiBtn');
btn.disabled = true; btn.disabled = true;
try { try {
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai`, { method: 'POST', headers: hdr() }); const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance-ai`, {
method: 'POST',
headers: hdrJson(),
body: JSON.stringify({ model: mode.model, face: mode.face, face_model: mode.face_model }),
});
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка запуска ИИ'); throw new Error(err.error || 'Ошибка запуска ИИ');
@@ -630,12 +686,14 @@ async function runPhotoAi() {
const { jobId } = await res.json(); const { jobId } = await res.json();
enhanceAiJobId = jobId; enhanceAiJobId = jobId;
showEnhanceResult(false); showEnhanceResult(false);
setEnhanceLoadingText('ИИ обрабатывает фото... (может занять до минуты)'); setEnhanceLoadingText(mode.face === 'off'
? 'ИИ обрабатывает фото... (может занять до минуты)'
: 'ИИ обрабатывает фото и восстанавливает лица...');
btn.textContent = '⏳ ИИ...'; btn.textContent = '⏳ ИИ...';
const result = await new Promise((resolve, reject) => { const result = await new Promise((resolve, reject) => {
photoAiPollTimer = setInterval(async () => { photoAiPollTimer = setInterval(async () => {
try { try {
const r = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai/${jobId}`, { headers: hdr() }); const r = await fetch(`${API}/api/entries/${entryId}/photo/enhance-ai/${jobId}`, { headers: hdr() });
if (!r.ok) throw new Error('Ошибка опроса статуса'); if (!r.ok) throw new Error('Ошибка опроса статуса');
const s = await r.json(); const s = await r.json();
if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); } if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); }
@@ -715,7 +773,10 @@ function openEnhance(id) {
orig.src = `${API}${mp}`; orig.src = `${API}${mp}`;
document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none'; document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none';
loadEnhanceEngine().then(() => { loadEnhanceEngine().then(() => {
document.getElementById('enhanceAiBtn').style.display = photoAiEnabled ? '' : 'none'; const show = photoAiEnabled ? '' : 'none';
document.getElementById('enhanceAiBtn').style.display = show;
document.getElementById('enhanceAiMode').style.display = show;
updateAiHint();
}); });
const canvas = document.getElementById('enhanceCanvas'); const canvas = document.getElementById('enhanceCanvas');
setEnhanceClip(50); setEnhanceClip(50);
+89 -2
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', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
const NOTIFY_IDS = []; 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-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
const SPAM_MAX = 10080; const SPAM_MAX = 10080;
const BAN_REASONS = { const BAN_REASONS = {
'honeypot': 'Антиспам-поле', 'honeypot': 'Антиспам-поле',
@@ -12,6 +12,7 @@ const BAN_REASONS = {
let currentLogo = ''; let currentLogo = '';
let logoOriginal = ''; let logoOriginal = '';
let currentSystemName = ''; let currentSystemName = '';
let photoAiService = null;
function renderLogoPreview() { function renderLogoPreview() {
const img = document.getElementById('logoPreviewImg'); const img = document.getElementById('logoPreviewImg');
@@ -217,6 +218,12 @@ async function loadSettings() {
const pe = document.getElementById('photoEngine'); const pe = document.getElementById('photoEngine');
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto'; pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
if (!pe.value) pe.value = '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'; document.getElementById('camEnabled').checked = s.camera_enabled !== 'false';
await renderNotifyTypes(s); await renderNotifyTypes(s);
snapshotDirty(); snapshotDirty();
@@ -225,6 +232,7 @@ async function loadSettings() {
loadAiProfiles(); loadAiProfiles();
renderStats(stRes); renderStats(stRes);
loadSystemInfo(); loadSystemInfo();
loadPhotoAiStatus();
} }
async function renderStats(res) { async function renderStats(res) {
@@ -369,6 +377,52 @@ function stackRow(label, value) {
return `<div class="sys-item"><span class="sys-label">${esc(label)}</span><span class="sys-value">${value}</span></div>`; 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) { function renderStackInfo(stack) {
const el = document.getElementById('stackInfo'); const el = document.getElementById('stackInfo');
if (!el) return; if (!el) return;
@@ -419,6 +473,16 @@ function renderStackInfo(stack) {
? stackRow('Бакет', esc(`${st.bucket || '—'}${st.region ? ` · ${st.region}` : ''}`)) ? stackRow('Бакет', esc(`${st.bucket || '—'}${st.region ? ` · ${st.region}` : ''}`))
: stackRow('Каталог', esc(st.dir || '—')); : 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 = ` el.innerHTML = `
<div class="sys-card"> <div class="sys-card">
<div class="sys-title">Приложение</div> <div class="sys-title">Приложение</div>
@@ -473,6 +537,20 @@ function renderStackInfo(stack) {
${stackRow('Локальный кеш', s3 ? esc(st.local_fallback ? 'включён' : 'выключен') : esc('—'))} ${stackRow('Локальный кеш', s3 ? esc(st.local_fallback ? 'включён' : 'выключен') : esc('—'))}
${s3 ? stackRow('Локальные копии', esc(st.keep_local ? 'хранятся' : 'удаляются')) : ''} ${s3 ? stackRow('Локальные копии', esc(st.keep_local ? 'хранятся' : 'удаляются')) : ''}
</div> </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>
`; `;
} }
@@ -737,6 +815,9 @@ async function saveSettings() {
photo_capture_resolution: document.getElementById('photoResolution').value, photo_capture_resolution: document.getElementById('photoResolution').value,
photo_capture_quality: String(document.getElementById('photoQuality').value), photo_capture_quality: String(document.getElementById('photoQuality').value),
photo_enhance_engine: document.getElementById('photoEngine').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', camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
trash_purge_days: String(trashDays), trash_purge_days: String(trashDays),
...notifySettingsPayload(notifyRetentionDays), ...notifySettingsPayload(notifyRetentionDays),
@@ -888,6 +969,12 @@ if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', () => loadSyste
const stackRefreshBtn = document.getElementById('stackRefreshBtn'); const stackRefreshBtn = document.getElementById('stackRefreshBtn');
if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(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'); const notifyTestBtn = document.getElementById('notifyTestBtn');
if (notifyTestBtn) notifyTestBtn.addEventListener('click', async () => { if (notifyTestBtn) notifyTestBtn.addEventListener('click', async () => {
notifyTestBtn.classList.add('spinning'); notifyTestBtn.classList.add('spinning');
+52 -3
View File
@@ -19,6 +19,7 @@ const STATUS_BADGE = {
let state = null; let state = null;
let photoState = null; let photoState = null;
let photoJobRow = null; let photoJobRow = null;
let photoJobMeta = {};
let refreshTimer = null; let refreshTimer = null;
let busy = false; let busy = false;
let photoBusy = false; let photoBusy = false;
@@ -29,6 +30,8 @@ let photoTotal = 0;
const PHOTO_ACTION_LABELS = { const PHOTO_ACTION_LABELS = {
ai: 'ИИ-обработка', ai: 'ИИ-обработка',
ai_face: 'ИИ + лица',
ai_upscale: 'ИИ-апскейл',
enhance: 'Улучшение', enhance: 'Улучшение',
restore: 'Возврат оригинала', restore: 'Возврат оригинала',
rollback: 'Откат', rollback: 'Откат',
@@ -224,9 +227,15 @@ function render() {
function renderPhoto() { function renderPhoto() {
if (!photoState) return; if (!photoState) return;
const w = photoState.worker || {}; const w = photoState.worker || {};
document.getElementById('photoStateHint').textContent = !photoState.enabled const svc = photoState.service || {};
? 'Обработка фотографий выключена' let hint;
: (!photoState.ai_configured ? 'PHOTO_AI_URL не настроен' : (w.processing ? `Обрабатывается задание #${w.current_id ?? ''}` : 'Ожидание очереди')); 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 ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер'; document.getElementById('photoToggleBtn').textContent = photoState.enabled ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер';
const c = photoState.counts || {}; const c = photoState.counts || {};
document.getElementById('photoCounts').innerHTML = [ document.getElementById('photoCounts').innerHTML = [
@@ -297,12 +306,52 @@ function setPhotoJobClip(p) {
document.getElementById('photoJobDivider').style.left = v + '%'; 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) { function openPhotoJob(r) {
photoJobRow = r; photoJobRow = r;
document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`; document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`;
document.getElementById('photoJobBefore').src = thumbUrl(r.before_path); document.getElementById('photoJobBefore').src = thumbUrl(r.before_path);
document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : ''; document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : '';
document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none'; document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none';
renderPhotoJobMeta(r);
loadPhotoJobMeta().then(() => { if (photoJobRow === r) renderPhotoJobMeta(r); });
setPhotoJobClip(50); setPhotoJobClip(50);
document.getElementById('photoJobModal').classList.add('open'); document.getElementById('photoJobModal').classList.add('open');
} }
+39
View File
@@ -26,6 +26,7 @@
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a> <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-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" 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-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-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a> <a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
@@ -259,6 +260,44 @@
</div> </div>
</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="settings-card" id="sec-trash">
<div class="card-head"> <div class="card-head">
<div class="ic"><i data-lucide="trash-2"></i></div> <div class="ic"><i data-lucide="trash-2"></i></div>
+1
View File
@@ -178,6 +178,7 @@
<div class="enhance-badge left">Было</div> <div class="enhance-badge left">Было</div>
<div class="enhance-badge right">Стало</div> <div class="enhance-badge right">Стало</div>
</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"> <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 ghost" id="photoJobCloseBtn">Закрыть</button>
<button type="button" class="btn-primary" id="photoJobRollbackBtn">↩ Откатить к версии «Было»</button> <button type="button" class="btn-primary" id="photoJobRollbackBtn">↩ Откатить к версии «Было»</button>