Add AI-check details modal on worker page and raise backup upload limit

This commit is contained in:
dev
2026-09-11 16:26:30 +03:00
parent ee19da7fa0
commit 4db02d75bc
6 changed files with 51 additions and 3 deletions
+2
View File
@@ -1,6 +1,8 @@
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=сложный-пароль-админки ADMIN_PASSWORD=сложный-пароль-админки
DB_PASSWORD=случайная-длинная-строка DB_PASSWORD=случайная-длинная-строка
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
BACKUP_UPLOAD_LIMIT_MB=500
# Hugging Face token (нужен для закрытых/приватных репозиториев моделей) # Hugging Face token (нужен для закрытых/приватных репозиториев моделей)
HUGGINGFACE_TOKEN= HUGGINGFACE_TOKEN=
+1
View File
@@ -29,6 +29,7 @@ services:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf} AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
AI_PROMPT: ${AI_PROMPT:-} AI_PROMPT: ${AI_PROMPT:-}
NODE_ENV: production NODE_ENV: production
+6
View File
@@ -373,6 +373,12 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.ai-badge-skipped,.ai-badge-reverted{background:rgba(148,163,184,.18);color:var(--muted)} .ai-badge-skipped,.ai-badge-reverted{background:rgba(148,163,184,.18);color:var(--muted)}
.ai-badge-ic.ai-badge-processing .lucide-loader{animation:spin 1s linear infinite} .ai-badge-ic.ai-badge-processing .lucide-loader{animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}} @keyframes spin{to{transform:rotate(360deg)}}
.ai-details{background:none;border:none;padding:0;margin:0;color:var(--accent);font-size:.8rem;cursor:pointer;white-space:nowrap}
.ai-details:hover{text-decoration:underline}
.ai-detail-block{margin-top:4px}
.ai-detail-block + .ai-detail-block{margin-top:14px}
.ai-detail-label{font-size:.75rem;color:var(--muted);margin-bottom:6px;font-weight:600}
.ai-detail-block pre{margin:0;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;line-height:1.45;color:var(--text);max-height:34vh;overflow:auto}
.ai-status{margin-top:6px;font-size:.78rem;color:var(--muted)} .ai-status{margin-top:6px;font-size:.78rem;color:var(--muted)}
.ai-queue{margin-top:8px;font-size:.78rem;color:var(--muted)} .ai-queue{margin-top:8px;font-size:.78rem;color:var(--muted)}
+21 -1
View File
@@ -17,6 +17,7 @@ const STATUS_BADGE = {
let state = null; let state = null;
let refreshTimer = null; let refreshTimer = null;
let busy = false; let busy = false;
let recentRows = [];
function fmtDur(ms) { function fmtDur(ms) {
if (ms === null || ms === undefined || ms < 0) return '—'; if (ms === null || ms === undefined || ms < 0) return '—';
@@ -141,13 +142,15 @@ function renderRecent() {
const empty = document.getElementById('recentEmpty'); const empty = document.getElementById('recentEmpty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; } if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none'; empty.style.display = 'none';
tbody.innerHTML = rows.map(r => ` recentRows = rows;
tbody.innerHTML = rows.map((r, i) => `
<tr> <tr>
<td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td> <td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td>
<td>${esc(r.student_name)}</td> <td>${esc(r.student_name)}</td>
<td style="color:var(--muted)">${esc(r.group_name || '')}</td> <td style="color:var(--muted)">${esc(r.group_name || '')}</td>
<td>${statusBadge(r.ai_status)}</td> <td>${statusBadge(r.ai_status)}</td>
<td>${r.changed ? '✏️ исправлен' : '—'}</td> <td>${r.changed ? '✏️ исправлен' : '—'}</td>
<td><button type="button" class="ai-details" data-ai-idx="${i}">Подробнее</button></td>
</tr>`).join(''); </tr>`).join('');
} }
@@ -225,6 +228,23 @@ document.getElementById('requeueBtn').addEventListener('click', requeueFailed);
document.getElementById('recentFilter').addEventListener('change', renderRecent); document.getElementById('recentFilter').addEventListener('change', renderRecent);
document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked)); document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked));
document.getElementById('recentTable').addEventListener('click', e => {
const btn = e.target.closest('.ai-details');
if (!btn) return;
const r = recentRows[parseInt(btn.dataset.aiIdx, 10)];
if (!r) return;
document.getElementById('aiDetailTitle').textContent = `ИИ-проверка #${r.id} — ${r.student_name}`;
document.getElementById('aiDetailInput').textContent = r.description_original || '—';
document.getElementById('aiDetailOutput').textContent = r.description_ai || '—';
document.getElementById('aiDetailModal').classList.add('open');
});
document.getElementById('aiDetailCloseBtn').addEventListener('click', () => {
document.getElementById('aiDetailModal').classList.remove('open');
});
document.getElementById('aiDetailModal').addEventListener('click', e => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
});
(async () => { (async () => {
if (await requireAdminPage()) { if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page); buildSidebar(document.body.dataset.page);
+17 -1
View File
@@ -69,7 +69,7 @@
<div class="audit-wrap"> <div class="audit-wrap">
<table class="audit-table" id="recentTable"> <table class="audit-table" id="recentTable">
<thead> <thead>
<tr><th>Проверено</th><th>Ученик</th><th>Группа</th><th>Статус</th><th>Изменения</th></tr> <tr><th>Проверено</th><th>Ученик</th><th>Группа</th><th>Статус</th><th>Изменения</th><th>Детали</th></tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
@@ -95,6 +95,22 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal-overlay" id="aiDetailModal">
<div class="edit-modal" style="max-width:680px">
<h3 id="aiDetailTitle">Детали ИИ-проверки</h3>
<div class="ai-detail-block">
<div class="ai-detail-label">Отправлено в модель</div>
<pre id="aiDetailInput"></pre>
</div>
<div class="ai-detail-block">
<div class="ai-detail-label">Ответ модели</div>
<pre id="aiDetailOutput"></pre>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="cancel" id="aiDetailCloseBtn">Закрыть</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
+4 -1
View File
@@ -749,6 +749,8 @@ async function aiCorrectText(text) {
} }
} }
const BACKUP_UPLOAD_LIMIT_MB = parseInt(process.env.BACKUP_UPLOAD_LIMIT_MB || '500', 10);
const uploadBackup = multer({ const uploadBackup = multer({
storage: multer.diskStorage({ storage: multer.diskStorage({
destination: (_, __, cb) => { destination: (_, __, cb) => {
@@ -760,7 +762,7 @@ const uploadBackup = multer({
cb(null, `backup-${Date.now()}${ext}`); cb(null, `backup-${Date.now()}${ext}`);
}, },
}), }),
limits: { fileSize: 50 * 1024 * 1024 }, limits: { fileSize: BACKUP_UPLOAD_LIMIT_MB * 1024 * 1024 },
}); });
const SAFE_NAME = /^[\w,.()-]+$/; const SAFE_NAME = /^[\w,.()-]+$/;
@@ -2633,6 +2635,7 @@ app.get('/api/ai/status', requireAdmin, async (_, res) => {
), ),
pool.query( pool.query(
`SELECT e.id, e.student_name, e.ai_status, e.ai_checked_at, e.ai_error, g.name AS group_name, `SELECT e.id, e.student_name, e.ai_status, e.ai_checked_at, e.ai_error, g.name AS group_name,
e.description_original, e.description_ai,
(e.description_ai IS NOT NULL AND e.description_original IS NOT NULL AND e.description_ai <> e.description_original) AS changed (e.description_ai IS NOT NULL AND e.description_original IS NOT NULL AND e.description_ai <> e.description_original) AS changed
FROM entries e JOIN groups g ON g.id = e.group_id FROM entries e JOIN groups g ON g.id = e.group_id
WHERE e.ai_checked_at IS NOT NULL AND e.deleted_at IS NULL WHERE e.ai_checked_at IS NOT NULL AND e.deleted_at IS NULL