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:
+89
-2
@@ -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 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 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');
|
||||
@@ -217,6 +218,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 +232,7 @@ async function loadSettings() {
|
||||
loadAiProfiles();
|
||||
renderStats(stRes);
|
||||
loadSystemInfo();
|
||||
loadPhotoAiStatus();
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
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 +473,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 +537,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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -737,6 +815,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),
|
||||
@@ -888,6 +969,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');
|
||||
|
||||
Reference in New Issue
Block a user