Add AI auto-check worker, share link message/link fields, and update journal/settings UI

This commit is contained in:
dev
2026-09-11 10:29:49 +03:00
parent 3850fe35e4
commit b7e798b867
20 changed files with 1403 additions and 127 deletions
+234
View File
@@ -0,0 +1,234 @@
const AUTO_REFRESH_MS = 5000;
const STATUS_LABELS = {
pending: 'В очереди',
processing: 'Проверяется',
done: 'Проверено',
skipped: 'Пропущено',
error: 'Ошибка',
reverted: 'Оригинал',
};
const STATUS_BADGE = {
done: 'ai-badge-done',
error: 'ai-badge-error',
skipped: 'ai-badge-skipped',
reverted: 'ai-badge-reverted',
};
let state = null;
let refreshTimer = null;
let busy = false;
function fmtDur(ms) {
if (ms === null || ms === undefined || ms < 0) return '—';
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (d) return `${d} д ${h} ч`;
if (h) return `${h} ч ${m} мин`;
if (m) return `${m} мин ${sec} с`;
return `${sec} с`;
}
function fmtTime(t) {
if (!t) return '—';
try { return new Date(t).toLocaleString('ru'); } catch { return String(t); }
}
function ago(t) {
if (!t) return '—';
const diff = Date.now() - new Date(t).getTime();
if (Number.isNaN(diff)) return '—';
return fmtDur(diff) + ' назад';
}
function statusBadge(s) {
return `<span class="ai-badge ${STATUS_BADGE[s] || ''}">${esc(STATUS_LABELS[s] || s)}</span>`;
}
function renderState() {
const w = state.worker || {};
const svc = state.service || {};
const enabled = state.enabled;
const processing = !!w.processing;
const dotClass = !enabled ? '' : (svc.reachable ? (processing ? 'busy' : 'on') : 'err');
const label = !enabled
? 'Автопроверка выключена'
: (!svc.reachable ? 'Сервис модели недоступен' : (processing ? `Обрабатывается запись #${w.current_id ?? ''}` : 'Ожидание очереди'));
document.getElementById('stateLine').innerHTML = `<span class="worker-dot ${dotClass}"></span> ${esc(label)}`;
document.getElementById('toggleBtn').textContent = enabled ? 'Выключить автопроверку' : 'Включить автопроверку';
}
function renderCards() {
const w = state.worker || {};
const svc = state.service || {};
const cfg = w.config || {};
const pct = w.checks ? Math.round(((w.corrected || 0) / w.checks) * 100) : 0;
document.getElementById('workerCards').innerHTML = `
<div class="sys-card">
<div class="sys-title">Состояние воркера</div>
<div class="sys-item"><span class="sys-label">Режим</span><span class="sys-value">${w.processing ? 'Обработка' : 'Ожидание'}</span></div>
<div class="sys-item"><span class="sys-label">Текущая запись</span><span class="sys-value">${w.current_id ? '#' + w.current_id : '—'}</span></div>
<div class="sys-item"><span class="sys-label">Работает</span><span class="sys-value">${fmtDur(w.uptime_ms)}</span></div>
<div class="sys-item"><span class="sys-label">Запущен</span><span class="sys-value">${fmtTime(w.started_at)}</span></div>
</div>
<div class="sys-card">
<div class="sys-title">Модель</div>
<div class="sys-item"><span class="sys-label">Модель</span><span class="sys-value">${esc(state.model)}</span></div>
<div class="sys-item"><span class="sys-label">Сервис</span><span class="sys-value">${svc.reachable ? '✅ доступен' : '❌ недоступен'}</span></div>
<div class="sys-item"><span class="sys-label">Отклик</span><span class="sys-value">${svc.latency_ms !== undefined ? svc.latency_ms + ' мс' : '—'}</span></div>
<div class="sys-item"><span class="sys-label">Адрес</span><span class="sys-value" style="font-size:.75rem">${esc(state.ai_url || '')}</span></div>
${svc.error ? `<div class="sys-meta" style="color:#ef4444">${esc(svc.error)}</div>` : ''}
</div>
<div class="sys-card">
<div class="sys-title">Итоги работы</div>
<div class="sys-item"><span class="sys-label">Проверок</span><span class="sys-value">${w.checks || 0}</span></div>
<div class="sys-item"><span class="sys-label">Исправлено</span><span class="sys-value">${w.corrected || 0} (${pct}%)</span></div>
<div class="sys-item"><span class="sys-label">Без изменений</span><span class="sys-value">${w.unchanged || 0}</span></div>
<div class="sys-item"><span class="sys-label">Ошибок</span><span class="sys-value">${w.errors || 0}</span></div>
<div class="sys-item"><span class="sys-label">Активность</span><span class="sys-value">${ago(w.last_at)}</span></div>
${w.last_error ? `<div class="sys-meta" style="color:#ef4444">${esc(w.last_error)}</div>` : ''}
</div>
<div class="sys-card">
<div class="sys-title">Параметры (мин. нагрузка)</div>
<div class="sys-item"><span class="sys-label">Пауза простоя</span><span class="sys-value">${fmtDur(cfg.idle_min_ms)} → ${fmtDur(cfg.idle_max_ms)}</span></div>
<div class="sys-item"><span class="sys-label">Таймаут запроса</span><span class="sys-value">${fmtDur(cfg.request_timeout_ms)}</span></div>
<div class="sys-item"><span class="sys-label">Макс. текст</span><span class="sys-value">${cfg.max_input_chars || 0} симв.</span></div>
<div class="sys-item"><span class="sys-label">Мин. текст</span><span class="sys-value">${cfg.min_text_chars || 0} симв.</span></div>
<div class="sys-item"><span class="sys-label">Повторы при сбое</span><span class="sys-value">${cfg.max_attempts || 0}</span></div>
</div>
`;
}
function renderCounts() {
const c = state.counts || {};
const chips = [
['pending', 'В очереди', c.pending || 0],
['processing', 'Проверяется', c.processing || 0],
['done', 'Проверено', c.done || 0],
['skipped', 'Пропущено', c.skipped || 0],
['error', 'Ошибка', c.error || 0],
['reverted', 'Оригинал', c.reverted || 0],
];
document.getElementById('workerCounts').innerHTML = chips
.map(([cls, label, n]) => `<span class="chip ${cls}">${label}: <b>${n}</b></span>`)
.join('');
}
function renderPending() {
const rows = state.pending || [];
document.getElementById('pendingCount').textContent = rows.length;
const tbody = document.querySelector('#pendingTable tbody');
const empty = document.getElementById('pendingEmpty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => `
<tr>
<td>#${r.id}</td>
<td>${esc(r.student_name)}</td>
<td style="color:var(--muted)">${esc(r.group_name || '')}</td>
<td style="color:var(--muted);white-space:nowrap">${fmtTime(r.created_at)}</td>
</tr>`).join('');
}
function renderRecent() {
const filter = document.getElementById('recentFilter').value;
const all = state.recent || [];
const rows = filter ? all.filter(r => r.ai_status === filter) : all;
document.getElementById('recentCount').textContent = `${rows.length} из ${all.length}`;
const tbody = document.querySelector('#recentTable tbody');
const empty = document.getElementById('recentEmpty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => `
<tr>
<td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td>
<td>${esc(r.student_name)}</td>
<td style="color:var(--muted)">${esc(r.group_name || '')}</td>
<td>${statusBadge(r.ai_status)}</td>
<td>${r.changed ? '✏️ исправлен' : '—'}</td>
</tr>`).join('');
}
function renderErrors() {
const rows = state.errors || [];
document.getElementById('errorsCount').textContent = rows.length;
document.getElementById('errorsCard').style.display = rows.length ? 'flex' : 'none';
document.querySelector('#errorsTable tbody').innerHTML = rows.map(r => `
<tr>
<td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td>
<td>#${r.id}</td>
<td>${esc(r.student_name)}</td>
<td style="color:#ef4444">${esc(r.ai_error || '')}</td>
</tr>`).join('');
}
function render() {
if (!state) return;
renderState();
renderCards();
renderCounts();
renderPending();
renderRecent();
renderErrors();
}
async function load() {
if (busy) return;
busy = true;
try {
const res = await fetch(`${API}/api/ai/status`, { headers: hdr() });
if (!res.ok) return;
state = await res.json();
render();
} catch {
} finally {
busy = false;
}
}
async function post(path, body) {
const opts = { method: 'POST', headers: body ? hdrJson() : hdr() };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${API}${path}`, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Ошибка'); return null; }
return data;
}
async function wake() {
if (await post('/api/ai/wake')) { showToast('Воркер разбужен'); load(); }
}
async function toggleEnabled() {
const next = !(state && state.enabled);
const data = await post('/api/ai/enabled', { enabled: next });
if (data) { showToast(next ? 'Автопроверка включена' : 'Автопроверка выключена'); load(); }
}
async function requeueFailed() {
if (!confirm('Вернуть все записи с ошибкой в очередь на повторную проверку?')) return;
const data = await post('/api/ai/requeue-failed');
if (data) { showToast(`В очередь возвращено: ${data.count}`); load(); }
}
function setAutoRefresh(on) {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
if (on) refreshTimer = setInterval(load, AUTO_REFRESH_MS);
}
document.getElementById('refreshBtn').addEventListener('click', load);
document.getElementById('wakeBtn').addEventListener('click', wake);
document.getElementById('toggleBtn').addEventListener('click', toggleEnabled);
document.getElementById('requeueBtn').addEventListener('click', requeueFailed);
document.getElementById('recentFilter').addEventListener('change', renderRecent);
document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked));
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
load();
}
})();