diff --git a/public/journal.html b/public/journal.html index 6e0acd5..e54ed8a 100644 --- a/public/journal.html +++ b/public/journal.html @@ -161,6 +161,38 @@ + +
diff --git a/public/js/index.js b/public/js/index.js index 9696997..724f781 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -21,6 +21,9 @@ const sentText = document.getElementById('sentText'); const sentTimer = document.getElementById('sentTimer'); const SENT_KEY = 'whatido_entry_sent_at'; let entryCooldownMin = 30; +let captureWidth = 640; +let captureHeight = 480; +let captureQuality = 0.92; let sentTimerId = null; function unlockForm() { @@ -156,6 +159,12 @@ if (params.get('nameInput')) { if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right; const c = parseInt(f.spam_interval_min, 10); if (Number.isFinite(c) && c >= 1) entryCooldownMin = c; + const w = parseInt(f.photo_capture_width, 10); + const h = parseInt(f.photo_capture_height, 10); + if (Number.isFinite(w) && w >= 160 && w <= 4096) captureWidth = w; + if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h; + const q = parseFloat(f.photo_capture_quality); + if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q; } catch (e) { /* ignore */ } checkPreviousSend(); })(); @@ -175,7 +184,7 @@ function removePhoto() { async function openCam() { try { - stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } }); + stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } }); camVideo.srcObject = stream; camModal.classList.add('open'); } catch (e) { @@ -199,7 +208,7 @@ function capture() { capturedBlob = blob; showPreview(URL.createObjectURL(blob)); closeCam(); - }, 'image/jpeg', 0.85); + }, 'image/jpeg', captureQuality); } form.addEventListener('submit', async (e) => { diff --git a/public/js/journal.js b/public/js/journal.js index 3d61e10..600cb17 100644 --- a/public/js/journal.js +++ b/public/js/journal.js @@ -105,6 +105,7 @@ function gridEntryHTML(e) {
${new Date(e.created_at).toLocaleString('ru')}
+ ${mp ? `` : ''} `; @@ -127,6 +128,7 @@ function cardEntryHTML(e) { ${new Date(e.created_at).toLocaleString('ru')} + ${mp ? `` : ''} @@ -416,6 +418,172 @@ function closeEdit() { editDeletePhoto = false; } +let enhanceEntryId = null; +let enhanceSourceImg = null; +let enhanceTimer = null; +let enhanceSaving = false; + +function openEnhance(id) { + const e = currentEntries.find(x => x.id === id); + if (!e) return; + const mp = mainPhotoPath(e); + if (!mp) { showToast('У записи нет фото'); return; } + enhanceEntryId = id; + enhanceSourceImg = null; + ['Brightness', 'Contrast', 'Saturate'].forEach(k => { + const el = document.getElementById('enhance' + k); + el.value = 100; + document.getElementById('enhance' + k + 'Val').textContent = '100%'; + }); + const sharp = document.getElementById('enhanceSharp'); + sharp.value = 0; + document.getElementById('enhanceSharpVal').textContent = '0%'; + const orig = document.getElementById('enhanceOrig'); + orig.src = `${API}${mp}`; + const canvas = document.getElementById('enhanceCanvas'); + canvas.style.display = 'none'; + const loading = document.getElementById('enhanceLoading'); + loading.style.display = ''; + loading.textContent = 'Загрузка...'; + const img = new Image(); + img.onload = () => { + enhanceSourceImg = img; + renderEnhancePreview(); + }; + img.onerror = () => { + loading.textContent = 'Не удалось загрузить фото'; + }; + img.src = `${API}${mp}`; + document.getElementById('enhanceModal').classList.add('open'); +} + +function closeEnhance() { + document.getElementById('enhanceModal').classList.remove('open'); + enhanceEntryId = null; + enhanceSourceImg = null; +} + +function renderEnhancePreview() { + if (!enhanceSourceImg) return; + const img = enhanceSourceImg; + const canvas = document.getElementById('enhanceCanvas'); + const w = img.naturalWidth; + const h = img.naturalHeight; + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + const b = document.getElementById('enhanceBrightness').value; + const c = document.getElementById('enhanceContrast').value; + const s = document.getElementById('enhanceSaturate').value; + ctx.filter = `brightness(${b}%) contrast(${c}%) saturate(${s}%)`; + ctx.drawImage(img, 0, 0, w, h); + ctx.filter = 'none'; + const sharp = parseInt(document.getElementById('enhanceSharp').value, 10); + if (sharp > 0) { + const data = ctx.getImageData(0, 0, w, h); + const src = data.data; + const out = new Uint8ClampedArray(src); + const a = (sharp / 100) * 1.5; + for (let y = 1; y < h - 1; y++) { + for (let x = 1; x < w - 1; x++) { + const i = (y * w + x) * 4; + for (let ch = 0; ch < 3; ch++) { + const v = (1 + 4 * a) * src[i + ch] + - a * src[i - 4 + ch] - a * src[i + 4 + ch] + - a * src[i - w * 4 + ch] - a * src[i + w * 4 + ch]; + out[i + ch] = v; + } + } + } + data.data.set(out); + ctx.putImageData(data, 0, 0); + } + canvas.style.display = 'block'; + document.getElementById('enhanceLoading').style.display = 'none'; +} + +function scheduleEnhancePreview() { + document.getElementById('enhanceBrightnessVal').textContent = document.getElementById('enhanceBrightness').value + '%'; + document.getElementById('enhanceContrastVal').textContent = document.getElementById('enhanceContrast').value + '%'; + document.getElementById('enhanceSaturateVal').textContent = document.getElementById('enhanceSaturate').value + '%'; + document.getElementById('enhanceSharpVal').textContent = document.getElementById('enhanceSharp').value + '%'; + if (enhanceTimer) clearTimeout(enhanceTimer); + enhanceTimer = setTimeout(renderEnhancePreview, 120); +} + +function autoEnhance() { + if (!enhanceSourceImg) return; + const img = enhanceSourceImg; + const aw = 160; + const ah = Math.max(1, Math.round(img.naturalHeight * aw / img.naturalWidth)); + const ac = document.createElement('canvas'); + ac.width = aw; + ac.height = ah; + const actx = ac.getContext('2d'); + actx.drawImage(img, 0, 0, aw, ah); + const d = actx.getImageData(0, 0, aw, ah).data; + const hist = new Array(256).fill(0); + let total = 0; + for (let i = 0; i < d.length; i += 4) { + if (d[i + 3] < 16) continue; + hist[Math.round(0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2])]++; + total++; + } + const pct = p => { + let acc = 0; + const target = total * p; + for (let v = 0; v < 256; v++) { + acc += hist[v]; + if (acc >= target) return v; + } + return 255; + }; + let lo = pct(0.005); + let hi = pct(0.995); + if (hi - lo < 30) { + lo = Math.max(0, lo - 15); + hi = Math.min(255, hi + 15); + } + const contrast = Math.round(Math.min(150, Math.max(100, 25500 / (hi - lo)))); + const brightness = Math.round(Math.min(120, Math.max(85, 12800 / Math.max(1, (lo + hi) / 2)))); + const saturate = 106; + const sharp = 30; + document.getElementById('enhanceBrightness').value = brightness; + document.getElementById('enhanceContrast').value = contrast; + document.getElementById('enhanceSaturate').value = saturate; + document.getElementById('enhanceSharp').value = sharp; + scheduleEnhancePreview(); +} + +async function saveEnhance() { + if (!enhanceEntryId || !enhanceSourceImg || enhanceSaving) return; + const canvas = document.getElementById('enhanceCanvas'); + enhanceSaving = true; + const btn = document.getElementById('enhanceSaveBtn'); + btn.disabled = true; + btn.textContent = 'Сохранение...'; + try { + const blob = await new Promise((resolve, reject) => canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/jpeg', 0.92)); + const fd = new FormData(); + fd.append('photo', blob, 'enhanced.jpg'); + const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance`, { method: 'PUT', headers: hdr(), body: fd }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Ошибка сохранения'); + } + const savedId = enhanceEntryId; + showToast('Фото улучшено и сохранено'); + closeEnhance(); + refreshEntryCard(savedId); + } catch (e) { + alert(e.message); + } finally { + enhanceSaving = false; + btn.disabled = false; + btn.textContent = 'Применить'; + } +} + async function aiCorrect() { if (aiCorrecting) return; const textarea = document.getElementById('editDesc'); @@ -730,6 +898,8 @@ document.getElementById('grid').addEventListener('click', e => { if (det) { detachFile(parseInt(det.dataset.detach, 10)); return; } const ed = e.target.closest('[data-edit]'); if (ed) { openEdit(parseInt(ed.dataset.edit, 10)); return; } + const en = e.target.closest('[data-enhance]'); + if (en) { openEnhance(parseInt(en.dataset.enhance, 10)); return; } const dl = e.target.closest('[data-del]'); if (dl) { delEntry(parseInt(dl.dataset.del, 10)); } }); @@ -760,6 +930,14 @@ document.getElementById('editPhotoPreview').addEventListener('click', e => { }); document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect); +document.getElementById('enhanceCancelBtn').addEventListener('click', closeEnhance); +document.getElementById('enhanceAutoBtn').addEventListener('click', autoEnhance); +document.getElementById('enhanceSaveBtn').addEventListener('click', saveEnhance); +document.getElementById('enhanceModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEnhance(); }); +document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview); +document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview); +document.getElementById('enhanceSaturate').addEventListener('input', scheduleEnhancePreview); +document.getElementById('enhanceSharp').addEventListener('input', scheduleEnhancePreview); document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect); document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect); document.getElementById('aiCorrectModal').addEventListener('click', e => { diff --git a/public/js/settings.js b/public/js/settings.js index f5362aa..38f8fcc 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1,4 +1,4 @@ -const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText']; +const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality']; const SECTION_IDS = ['sec-system', 'sec-antispam', 'sec-share', 'sec-form', 'sec-ai', 'sec-backup', 'sec-bans']; const SPAM_MAX = 10080; const BAN_REASONS = { @@ -81,7 +81,9 @@ function updateDirty() { function bindDirty() { DIRTY_FIELDS.forEach(id => { - document.getElementById(id).addEventListener('input', updateDirty); + const el = document.getElementById(id); + el.addEventListener('input', updateDirty); + el.addEventListener('change', updateDirty); }); } @@ -151,6 +153,10 @@ async function loadSettings() { document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false'; document.getElementById('shareShowPhotos').checked = s.share_show_group_photos !== 'false'; document.getElementById('cookieNoticeText').value = s.cookie_notice_text || ''; + const pr = document.getElementById('photoResolution'); + if (s.photo_capture_resolution) pr.value = s.photo_capture_resolution; + if (!pr.value) pr.value = '640x480'; + document.getElementById('photoQuality').value = s.photo_capture_quality !== undefined && s.photo_capture_quality !== '' ? s.photo_capture_quality : '0.92'; snapshotDirty(); updateSpamUi(); loadAiQueue(); @@ -274,7 +280,7 @@ async function loadAiProfiles() {
${esc(p.name)}${data.active === p.id ? ' по умолчанию' : ''}
-
${esc(p.model)} · ${esc(p.base_url)}
+
${esc(p.model)} · ${esc(p.base_url)}${p.max_tokens ? ` · max_tokens: ${esc(p.max_tokens)}` : ''}
${p.native ? '' : ``} @@ -295,6 +301,7 @@ function openAiProfileForm(profile) { document.getElementById('aiProfileModel').value = profile ? profile.model : ''; document.getElementById('aiProfileBaseUrl').value = profile ? profile.base_url : ''; document.getElementById('aiProfileApiKey').value = ''; + document.getElementById('aiProfileMaxTokens').value = profile && profile.max_tokens ? profile.max_tokens : ''; document.getElementById('aiProfileTestStatus').textContent = ''; document.getElementById('aiProfileName').focus(); } @@ -310,6 +317,7 @@ function aiProfileFormData() { model: document.getElementById('aiProfileModel').value, base_url: document.getElementById('aiProfileBaseUrl').value, api_key: document.getElementById('aiProfileApiKey').value, + max_tokens: document.getElementById('aiProfileMaxTokens').value.trim() === '' ? null : document.getElementById('aiProfileMaxTokens').value, }; } @@ -486,6 +494,8 @@ async function saveSettings() { share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false', share_show_group_photos: document.getElementById('shareShowPhotos').checked ? 'true' : 'false', cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(), + photo_capture_resolution: document.getElementById('photoResolution').value, + photo_capture_quality: String(document.getElementById('photoQuality').value), } }) }); if (res.ok) { diff --git a/public/settings.html b/public/settings.html index 73eac9c..85eb262 100644 --- a/public/settings.html +++ b/public/settings.html @@ -23,6 +23,7 @@ Антиспам Публичные ссылки Форма ответа + Фото ИИ Бэкапы Блокировки @@ -169,6 +170,33 @@
+
+
+
+

Фотография с веб-камеры

+
+

Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.

+
+
+ + +
+
+ +
+ + 0.5–1.0 +
+ Больше значение — лучше качество и крупнее файл +
+
+
+
@@ -209,6 +237,11 @@
+
+ + + Оставьте пустым для авто. Для reasoning-моделей (gpt-oss, deepseek-r1) нужно 1024+ — иначе рассуждения не уместятся и ответ придёт пустым. +
diff --git a/server.js b/server.js index 703880b..24e8472 100644 --- a/server.js +++ b/server.js @@ -278,7 +278,7 @@ async function sendImageThumb(res, fp) { try { if (!fs.existsSync(tp)) { const tmp = tp + '.' + crypto.randomBytes(4).toString('hex') + '.tmp'; - await sharp(fp).rotate().resize({ width: THUMB_WIDTH, withoutEnlargement: true }).webp({ quality: 72 }).toFile(tmp); + await sharp(fp).rotate().resize({ width: THUMB_WIDTH, withoutEnlargement: true }).webp({ quality: 85 }).toFile(tmp); fs.renameSync(tmp, tp); } res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); @@ -579,7 +579,7 @@ async function convertPhoto(file) { // HEIC/HEIF conversion const outName = `${path.basename(file.path, path.extname(file.path))}.jpg`; const outPath = path.join(path.dirname(file.path), outName); - const jpeg = await heicConvert({ buffer: fs.readFileSync(file.path), format: 'JPEG', quality: 0.85 }); + const jpeg = await heicConvert({ buffer: fs.readFileSync(file.path), format: 'JPEG', quality: 0.92 }); fs.writeFileSync(outPath, jpeg); safeUnlink(file.path); file.path = outPath; @@ -673,6 +673,20 @@ async function ensureEntryAiColumns() { await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`); await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL`); + await pool.query(` + CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$ + BEGIN + IF (TG_OP = 'INSERT') THEN + PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text); + ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN + PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text); + END IF; + RETURN NULL; + END; + $$ LANGUAGE plpgsql + `); + await pool.query(`DROP TRIGGER IF EXISTS trg_entries_notify ON entries`); + await pool.query(`CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries FOR EACH ROW EXECUTE FUNCTION notify_entries_changed()`); await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true') ON CONFLICT (key) DO NOTHING`); const marker = await getSetting('ai_autocheck_migrated', ''); if (marker !== '1') { @@ -951,10 +965,20 @@ app.get('/api/settings', requireAdmin, async (_, res) => { app.get('/api/public-settings', apiLimiter, async (_, res) => { const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => { - const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min']; - const defaults = { spam_interval_min: '30' }; + const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality']; + const defaults = { spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92' }; const result = {}; for (const k of keys) result[k] = await getSetting(k, defaults[k] || ''); + const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution); + if (rm) { + result.photo_capture_width = rm[1]; + result.photo_capture_height = rm[2]; + } else { + result.photo_capture_width = '640'; + result.photo_capture_height = '480'; + } + const q = parseFloat(result.photo_capture_quality); + result.photo_capture_quality = Number.isFinite(q) && q >= 0.5 && q <= 1 ? String(q) : '0.92'; return result; }); res.json(out); @@ -970,6 +994,17 @@ app.put('/api/settings', requireAdmin, async (req, res) => { return res.status(400).json({ error: 'spam_interval_min должен быть целым числом от 1 до 10080 (7 дней)' }); } } + if (key === 'photo_capture_resolution') { + if (!/^\d{2,5}x\d{2,5}$/.test(String(value))) { + return res.status(400).json({ error: 'photo_capture_resolution должен быть в формате ШИРИНАxВЫСОТА, например 640x480' }); + } + } + if (key === 'photo_capture_quality') { + const q = parseFloat(String(value)); + if (!Number.isFinite(q) || q < 0.5 || q > 1) { + return res.status(400).json({ error: 'photo_capture_quality должен быть числом от 0.5 до 1' }); + } + } } const client = await pool.connect(); try { @@ -3627,6 +3662,40 @@ app.put('/api/entries/:id/photos/:photoId/main', requireAuth, async (req, res) = invalidateEntries(); res.json({ ok: true, photo_path: rows[0].photo_path }); }); +app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => { upload.single('photo')(req, res, next); }, async (req, res) => { + if (req.user.role !== 'admin') { + const acc = await entryAccessible(req.user, req.params.id); + if (!acc.found) return res.status(404).json({ error: 'Not found' }); + if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); + } + try { + const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]); + if (!cur.length) { + if (req.file) safeUnlink(req.file.path); + return res.status(404).json({ error: 'Запись не найдена' }); + } + const oldPath = cur[0].photo_path; + if (!oldPath) { + if (req.file) safeUnlink(req.file.path); + return res.status(400).json({ error: 'У записи нет фото' }); + } + if (!req.file) return res.status(400).json({ error: 'Нет файла' }); + await convertPhoto(req.file); + const newPath = `/uploads/${req.file.filename}`; + await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [newPath, req.params.id]); + await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, req.params.id, oldPath]); + safeUnlink(oldPath); + const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp'); + safeUnlink(oldThumb); + await logAudit(req, 'entry.photo.enhance', { entry_id: req.params.id, old_path: oldPath, new_path: newPath }); + invalidateEntries(); + res.json({ ok: true, photo_path: newPath }); + } catch (e) { + if (req.file) safeUnlink(req.file.path); + console.error('PUT /api/entries/:id/photo/enhance:', e); + res.status(500).json({ error: 'Ошибка замены фото' }); + } +}); app.delete('/api/entries/:id', requireAuth, async (req, res) => { if (req.user.role !== 'admin') { @@ -3695,11 +3764,18 @@ function validateProfileBody(body) { const base = String(body?.base_url || '').trim().replace(/\/+$/, ''); const model = String(body?.model || '').trim(); const apiKey = String(body?.api_key || '').trim(); + let maxTokens = null; + if (body?.max_tokens !== null && body?.max_tokens !== undefined && String(body.max_tokens).trim() !== '') { + maxTokens = parseInt(String(body.max_tokens), 10); + if (!Number.isFinite(maxTokens) || maxTokens < 16 || maxTokens > 32768) { + return { error: 'max_tokens должен быть целым числом от 16 до 32768' }; + } + } if (!name || name.length > 100) return { error: 'Укажите название профиля (до 100 символов)' }; if (!/^https?:\/\//i.test(base) || base.length > 300) return { error: 'Base URL должен быть корректным http(s)://… (до 300 символов)' }; if (!model || model.length > 150) return { error: 'Укажите название модели (до 150 символов)' }; if (apiKey.length > 300) return { error: 'API-ключ слишком длинный' }; - return { name, base_url: base, model, api_key: apiKey }; + return { name, base_url: base, model, api_key: apiKey, max_tokens: maxTokens }; } app.get('/api/ai/profiles', requireAdmin, async (_, res) => { diff --git a/worker.js b/worker.js index ff2be29..0c14380 100644 --- a/worker.js +++ b/worker.js @@ -128,7 +128,12 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm }); if (!res.ok) throw new Error(`AI service error: ${res.status}`); const data = await res.json(); - return (data.choices?.[0]?.message?.content || '').trim(); + const choice = data.choices && data.choices[0]; + const content = (choice && choice.message && choice.message.content || '').trim(); + if (!content) { + throw new Error(`Модель вернула пустой ответ (finish_reason=${choice && choice.finish_reason || 'unknown'})`); + } + return content; } finally { clearTimeout(timer); }