Add AI-check details modal on worker page and raise backup upload limit
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user