From 275ed46dfb2c978dcecb17b981dfcf3769d962b7 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 11 Sep 2026 13:12:13 +0300 Subject: [PATCH] Add HF model auto-download with configurable AI env, and share link visibility toggles --- .env.example | 11 ++++- db/init.sql | 15 +++++- db/migration.sql | 30 +++++++++++- db/migration_share_links.sql | 17 ++++++- docker-compose.yml | 26 ++++------ public/admin.css | 6 +-- public/journal.html | 30 +++++++++--- public/js/journal.js | 20 ++++++-- public/js/links.js | 36 +++++++++++--- public/js/settings.js | 10 +++- public/js/share.js | 92 ++++++++++++++++++++++++++++++++++-- public/links.html | 22 ++++++++- public/settings.html | 40 ++++++++++++++++ public/share.html | 10 ++++ server.js | 41 +++++++++------- start-text-corrector.sh | 22 +++++++++ worker.js | 4 +- 17 files changed, 365 insertions(+), 67 deletions(-) create mode 100755 start-text-corrector.sh diff --git a/.env.example b/.env.example index 448397e..0437563 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,12 @@ ADMIN_USERNAME=admin ADMIN_PASSWORD=сложный-пароль-админки -DB_PASSWORD=случайная-длинная-строка \ No newline at end of file +DB_PASSWORD=случайная-длинная-строка + +# Hugging Face token (нужен для закрытых/приватных репозиториев моделей) +HUGGINGFACE_TOKEN= +# Имя GGUF-файла модели для text-corrector (скачивается с Hugging Face, если отсутствует) +AI_MODEL=qwen2.5-1.5b-instruct-q4_k_m.gguf +# Репозиторий Hugging Face, откуда скачивается модель +AI_MODEL_REPO=Qwen/Qwen2.5-1.5B-Instruct-GGUF +# Промпт по умолчанию для автоисправления текстов (переопределяет встроенный, если в настройках не сохранён свой) +AI_PROMPT=Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений. \ No newline at end of file diff --git a/db/init.sql b/db/init.sql index 932dd0f..f593e9e 100644 --- a/db/init.sql +++ b/db/init.sql @@ -54,10 +54,18 @@ CREATE TABLE IF NOT EXISTS settings ( INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30') ON CONFLICT (key) DO NOTHING; -INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.') +INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('share_show_student_message', 'false') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('share_show_entry_date', 'false') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('share_show_student_names', 'true') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true') +ON CONFLICT (key) DO NOTHING; CREATE TABLE IF NOT EXISTS share_links ( id SERIAL PRIMARY KEY, @@ -67,11 +75,14 @@ CREATE TABLE IF NOT EXISTS share_links ( student_name VARCHAR(150), date_from DATE, date_to DATE, - anonymize_names BOOLEAN DEFAULT false, + show_student_names BOOLEAN, expires_at TIMESTAMPTZ, access_password_hash VARCHAR(255), message TEXT, link_url VARCHAR(500), + show_student_message BOOLEAN, + show_entry_date BOOLEAN, + show_group_photos BOOLEAN, created_at TIMESTAMPTZ DEFAULT now() ); diff --git a/db/migration.sql b/db/migration.sql index 45d1852..941545a 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -13,8 +13,14 @@ CREATE TABLE IF NOT EXISTS settings ( INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30') ON CONFLICT (key) DO NOTHING; -INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.') +INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.') ON CONFLICT (key) DO NOTHING; +UPDATE settings +SET value = 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.' +WHERE key = 'ai_prompt' AND value IN ( + 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.', + 'Ты — редактор текстов. Исправь ошибки. Верни ТОЛЬКО исправленный текст.' +); CREATE TABLE IF NOT EXISTS share_links ( id SERIAL PRIMARY KEY, @@ -29,6 +35,18 @@ CREATE TABLE IF NOT EXISTS share_links ( ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT; ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500); +ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_message BOOLEAN; +ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_entry_date BOOLEAN; +ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_group_photos BOOLEAN; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'share_links' AND column_name = 'anonymize_names') THEN + ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_names BOOLEAN; + UPDATE share_links SET show_student_names = (NOT anonymize_names) WHERE anonymize_names IS NOT NULL; + ALTER TABLE share_links DROP COLUMN anonymize_names; + END IF; +END $$; DO $$ BEGIN @@ -76,6 +94,16 @@ CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('share_show_student_message', 'false') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('share_show_entry_date', 'false') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('share_show_student_names', 'true') +ON CONFLICT (key) DO NOTHING; +DELETE FROM settings WHERE key = 'share_anonymize_names'; +INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true') +ON CONFLICT (key) DO NOTHING; + DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN diff --git a/db/migration_share_links.sql b/db/migration_share_links.sql index 269dae1..212a1e7 100644 --- a/db/migration_share_links.sql +++ b/db/migration_share_links.sql @@ -18,4 +18,19 @@ CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at) -- 6. Add public message and external link shown on the share page ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT; -ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500); \ No newline at end of file +ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500); + +-- 7. Per-link visibility overrides (NULL = fall back to global settings) +ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_message BOOLEAN; +ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_entry_date BOOLEAN; +ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_group_photos BOOLEAN; + +-- 8. Replace anonymize_names with positive show_student_names (NULL = inherit global setting) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'share_links' AND column_name = 'anonymize_names') THEN + ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_names BOOLEAN; + UPDATE share_links SET show_student_names = (NOT anonymize_names) WHERE anonymize_names IS NOT NULL; + ALTER TABLE share_links DROP COLUMN anonymize_names; + END IF; +END $$; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 263ac0a..8bb4dad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,8 @@ services: DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf} + AI_PROMPT: ${AI_PROMPT:-} NODE_ENV: production TZ: Europe/Moscow depends_on: @@ -65,28 +67,20 @@ services: container_name: text-corrector restart: unless-stopped + entrypoint: ["/bin/sh", "/start-text-corrector.sh"] + + environment: + AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf} + AI_MODEL_REPO: ${AI_MODEL_REPO:-Qwen/Qwen2.5-1.5B-Instruct-GGUF} + HUGGINGFACE_TOKEN: ${HUGGINGFACE_TOKEN:-} + volumes: - ./models:/models + - ./start-text-corrector.sh:/start-text-corrector.sh:ro ports: - "8080:8080" - command: - - -m - - /models/qwen2.5-1.5b-instruct-q4_k_m.gguf - - --host - - 0.0.0.0 - - --port - - "8080" - - -c - - "4096" - - -n - - "256" - - -t - - "8" - - --temp - - "0.1" - diff --git a/public/admin.css b/public/admin.css index d2ef58b..4654ff4 100644 --- a/public/admin.css +++ b/public/admin.css @@ -287,7 +287,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .bar:hover{opacity:1} .bar.today{opacity:1} .bar-day{font-size:.62rem;color:var(--muted);white-space:nowrap} -.recent{display:flex;flex-direction:column;gap:2px} +.recent{display:grid;grid-template-columns:repeat(2,1fr);gap:2px 16px} .recent-item{display:flex;align-items:center;gap:12px;padding:8px 6px;border-radius:10px;transition:background .15s} .recent-item:hover{background:var(--bg)} .recent-item img{width:44px;height:44px;border-radius:8px;object-fit:cover;flex-shrink:0;cursor:pointer} @@ -321,11 +321,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .stat .label a{color:var(--accent);text-decoration:none;font-weight:600} .stat .label a:hover{text-decoration:underline} @media(max-width:1100px){.dash-grid{grid-template-columns:1fr}} -@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}} +@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}.recent{grid-template-columns:1fr}} /* --- System Info Dashboard --- */ .sys-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px} @media(max-width:480px){.sys-grid{grid-template-columns:1fr;gap:10px}} -.sys-card{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:8px;min-width:0} +.sys-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:8px;min-width:0} .sys-card.loading{color:var(--muted);justify-content:center;align-items:center;min-height:80px} .sys-card.err{color:#ef4444} .sys-title{font-size:.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px;padding-bottom:8px;border-bottom:1px solid var(--border)} diff --git a/public/journal.html b/public/journal.html index fe26598..ace249a 100644 --- a/public/journal.html +++ b/public/journal.html @@ -129,12 +129,30 @@ -
- -
+
+ +
+
+ +
+
+ +
+
+ +
diff --git a/public/js/journal.js b/public/js/journal.js index dda206b..bf50723 100644 --- a/public/js/journal.js +++ b/public/js/journal.js @@ -10,6 +10,15 @@ let searchTimer = null; let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list'; let aiCorrecting = false; let editDeletePhoto = false; +let linkSettings = null; + +async function loadLinkSettings() { + if (!linkSettings) { + const res = await fetch(`${API}/api/settings`, { headers: hdr() }); + linkSettings = await res.json().catch(() => ({})); + } + return linkSettings; +} function isImageFile(name) { return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || ''); @@ -531,10 +540,15 @@ async function openLinkCreate() { document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`; document.getElementById('linkStudent').value = currentStudent; document.getElementById('linkExpiresDays').value = '7'; - document.getElementById('linkAnonymizeNames').checked = false; + document.getElementById('linkShowNames').checked = false; document.getElementById('linkPassword').value = ''; document.getElementById('linkMessage').value = ''; document.getElementById('linkUrl').value = ''; + const s = await loadLinkSettings(); + document.getElementById('linkShowNames').checked = s.share_show_student_names !== 'false'; + document.getElementById('linkShowMessage').checked = s.share_show_student_message === 'true'; + document.getElementById('linkShowDate').checked = s.share_show_entry_date === 'true'; + document.getElementById('linkShowPhotos').checked = s.share_show_group_photos !== 'false'; document.getElementById('linkCreateView').style.display = ''; document.getElementById('linkResultView').style.display = 'none'; document.getElementById('linkModal').classList.add('open'); @@ -551,7 +565,7 @@ async function createLink() { const date_from = document.getElementById('dateFrom').value; const date_to = document.getElementById('dateTo').value; const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10); - const anonymize_names = document.getElementById('linkAnonymizeNames').checked; + const show_student_names = document.getElementById('linkShowNames').checked; const access_password = document.getElementById('linkPassword').value; const message = document.getElementById('linkMessage').value.trim(); const link_url = document.getElementById('linkUrl').value.trim(); @@ -561,7 +575,7 @@ async function createLink() { const res = await fetch(`${API}/api/links`, { method: 'POST', headers: hdrJson(), - body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null }) + body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, show_student_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null, show_student_message: document.getElementById('linkShowMessage').checked, show_entry_date: document.getElementById('linkShowDate').checked, show_group_photos: document.getElementById('linkShowPhotos').checked }) }); if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; } const link = await res.json(); diff --git a/public/js/links.js b/public/js/links.js index d2ecaca..4f20551 100644 --- a/public/js/links.js +++ b/public/js/links.js @@ -2,15 +2,27 @@ let links = []; let groups = []; let editingId = null; let studentsLoaded = false; +let linkSettings = null; + +async function loadLinkSettings() { + if (!linkSettings) { + const res = await fetch(`${API}/api/settings`, { headers: hdr() }); + linkSettings = await res.json().catch(() => ({})); + } + return linkSettings; +} function linkUrl(token) { return `${location.origin}/s/${token}`; } async function loadLinks() { + await loadLinkSettings(); const res = await fetch(`${API}/api/links`, { headers: hdr() }); links = await res.json(); const el = document.getElementById('linksList'); if (!links.length) { el.innerHTML = '
Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».
'; return; } - el.innerHTML = links.map(l => ` + el.innerHTML = links.map(l => { + const namesHidden = l.show_student_names != null ? !l.show_student_names : linkSettings.share_show_student_names === 'false'; + return `
${esc(l.name)}
@@ -19,7 +31,7 @@ async function loadLinks() { ${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: ${esc(l.student_name)}` : ''} ${l.date_from || l.date_to ? ` · период: ${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'} — ${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}` : ' · весь период'} ${l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''} - ${l.anonymize_names ? ` · анонимизировано` : ''} + ${namesHidden ? ` · имена скрыты` : ''} ${l.access_password_hash ? ` · 🔒 пароль` : ''} · создана ${new Date(l.created_at).toLocaleString('ru')}
@@ -31,7 +43,7 @@ async function loadLinks() {
- `).join(''); + `;}).join(''); } function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; } @@ -43,10 +55,15 @@ async function openLinkModal() { document.getElementById('linkDateFrom').value = ''; document.getElementById('linkDateTo').value = ''; document.getElementById('linkExpiresDays').value = '7'; - document.getElementById('linkAnonymizeNames').checked = false; + document.getElementById('linkShowNames').checked = false; document.getElementById('linkPassword').value = ''; document.getElementById('linkMessage').value = ''; document.getElementById('linkUrl').value = ''; + const s = await loadLinkSettings(); + document.getElementById('linkShowNames').checked = s.share_show_student_names !== 'false'; + document.getElementById('linkShowMessage').checked = s.share_show_student_message === 'true'; + document.getElementById('linkShowDate').checked = s.share_show_entry_date === 'true'; + document.getElementById('linkShowPhotos').checked = s.share_show_group_photos !== 'false'; if (!groups.length) { const res = await fetch(`${API}/api/groups`, { headers: hdr() }); groups = await res.json(); @@ -72,10 +89,15 @@ async function openLinkEdit(id) { document.getElementById('linkDateFrom').value = l.date_from || ''; document.getElementById('linkDateTo').value = l.date_to || ''; document.getElementById('linkExpiresDays').value = l.expires_at ? Math.ceil((new Date(l.expires_at) - new Date()) / (1000 * 60 * 60 * 24)) : '7'; - document.getElementById('linkAnonymizeNames').checked = l.anonymize_names || false; + document.getElementById('linkShowNames').checked = l.show_student_names != null ? !!l.show_student_names : true; document.getElementById('linkPassword').value = ''; document.getElementById('linkMessage').value = l.message || ''; document.getElementById('linkUrl').value = l.link_url || ''; + const s = await loadLinkSettings(); + document.getElementById('linkShowNames').checked = l.show_student_names != null ? !!l.show_student_names : s.share_show_student_names !== 'false'; + document.getElementById('linkShowMessage').checked = l.show_student_message != null ? !!l.show_student_message : s.share_show_student_message === 'true'; + document.getElementById('linkShowDate').checked = l.show_entry_date != null ? !!l.show_entry_date : s.share_show_entry_date === 'true'; + document.getElementById('linkShowPhotos').checked = l.show_group_photos != null ? !!l.show_group_photos : s.share_show_group_photos !== 'false'; if (!groups.length) { const res = await fetch(`${API}/api/groups`, { headers: hdr() }); groups = await res.json(); @@ -106,14 +128,14 @@ async function saveLink() { const date_from = document.getElementById('linkDateFrom').value; const date_to = document.getElementById('linkDateTo').value; const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10); - const anonymize_names = document.getElementById('linkAnonymizeNames').checked; + const show_student_names = document.getElementById('linkShowNames').checked; const access_password = document.getElementById('linkPassword').value; const message = document.getElementById('linkMessage').value.trim(); const link_url = document.getElementById('linkUrl').value.trim(); if (!name) { alert('Введите название'); return; } if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; } const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString(); - const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null }); + const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, show_student_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null, show_student_message: document.getElementById('linkShowMessage').checked, show_entry_date: document.getElementById('linkShowDate').checked, show_group_photos: document.getElementById('linkShowPhotos').checked }); const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, { method: editingId ? 'PUT' : 'POST', headers: hdrJson(), diff --git a/public/js/settings.js b/public/js/settings.js index 49f86ce..4c32eb8 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1,4 +1,4 @@ -const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck']; +const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos']; function fieldValue(el) { return el.type === 'checkbox' ? String(el.checked) : el.value; @@ -47,6 +47,10 @@ async function loadSettings() { document.getElementById('footerRight').value = s.footer_right || ''; document.getElementById('aiPrompt').value = s.ai_prompt || ''; document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false'; + document.getElementById('shareShowMessage').checked = s.share_show_student_message === 'true'; + document.getElementById('shareShowDate').checked = s.share_show_entry_date === 'true'; + document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false'; + document.getElementById('shareShowPhotos').checked = s.share_show_group_photos !== 'false'; snapshotDirty(); loadAiQueue(); if (stRes.ok) { @@ -158,6 +162,10 @@ async function saveSettings() { footer_right: document.getElementById('footerRight').value.trim(), ai_prompt: document.getElementById('aiPrompt').value.trim(), ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false', + share_show_student_message: document.getElementById('shareShowMessage').checked ? 'true' : 'false', + share_show_entry_date: document.getElementById('shareShowDate').checked ? 'true' : 'false', + share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false', + share_show_group_photos: document.getElementById('shareShowPhotos').checked ? 'true' : 'false', } }) }); if (res.ok) { diff --git a/public/js/share.js b/public/js/share.js index 2c0766a..314ea6c 100644 --- a/public/js/share.js +++ b/public/js/share.js @@ -2,6 +2,81 @@ function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/ 0) parts.push(`${days} ${declension(days, 'день', 'дня', 'дней')}`); + if (hours > 0) parts.push(`${hours} ${declension(hours, 'час', 'часа', 'часов')}`); + if (minutes > 0) parts.push(`${minutes} ${declension(minutes, 'минута', 'минуты', 'минут')}`); + if (days === 0) parts.push(`${seconds} ${declension(seconds, 'секунда', 'секунды', 'секунд')}`); + return parts.join(' '); +} + +function declension(n, one, few, many) { + const mod10 = n % 10; + const mod100 = n % 100; + if (mod100 >= 11 && mod100 <= 19) return many; + if (mod10 === 1) return one; + if (mod10 >= 2 && mod10 <= 4) return few; + return many; +} + +function shouldShowCountdown() { + const hero = document.querySelector('.hero'); + if (!hero) return true; + return window.scrollY > hero.offsetHeight - 80; +} + +function syncCountdownVisibility() { + const el = document.getElementById('countdown'); + if (!countdownTarget) { el.classList.remove('visible'); return; } + if (countdownTarget - Date.now() <= 0) { el.classList.remove('visible'); return; } + if (shouldShowCountdown()) el.classList.add('visible'); + else el.classList.remove('visible'); +} + +function updateCountdown() { + const el = document.getElementById('countdown'); + if (!countdownTarget) { el.classList.remove('visible'); return; } + const now = Date.now(); + const remaining = countdownTarget - now; + if (remaining <= 0) { + el.classList.remove('visible'); + if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } + return; + } + const text = formatRemaining(remaining); + if (!text) { el.classList.remove('visible'); return; } + el.classList.remove('warn', 'urgent'); + if (remaining < 3600000) el.classList.add('urgent'); + else if (remaining < 86400000) el.classList.add('warn'); + el.querySelector('.cd-text').innerHTML = `Ссылка доступна ещё ${text}`; + syncCountdownVisibility(); +} + +function startCountdown(expiresAt) { + if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } + if (countdownScrollHandler) { window.removeEventListener('scroll', countdownScrollHandler); countdownScrollHandler = null; } + const el = document.getElementById('countdown'); + if (!expiresAt) { el.classList.remove('visible'); return; } + countdownTarget = new Date(expiresAt).getTime(); + countdownScrollHandler = syncCountdownVisibility; + window.addEventListener('scroll', countdownScrollHandler, { passive: true }); + updateCountdown(); + countdownInterval = setInterval(updateCountdown, 1000); +} + function fileType(name){ if(/\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')) return 'image'; if(/\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(name||'')) return 'music'; @@ -98,6 +173,9 @@ function showError(msg) { document.getElementById('photosBlock').style.display = 'none'; document.getElementById('shareMessage').style.display = 'none'; document.getElementById('shareLinkBlock').style.display = 'none'; + document.getElementById('countdown').classList.remove('visible'); + if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } + if (countdownScrollHandler) { window.removeEventListener('scroll', countdownScrollHandler); countdownScrollHandler = null; } } function renderShare(data, password) { @@ -109,6 +187,8 @@ function renderShare(data, password) { if (data.date_from || data.date_to) chips.push(`Период: ${fmt(data.date_from)} — ${fmt(data.date_to)}`); document.getElementById('chips').innerHTML = chips.map(c => `${c}`).join(''); + startCountdown(data.expires_at); + const msgEl = document.getElementById('shareMessage'); if (data.message) { msgEl.textContent = data.message; @@ -133,7 +213,7 @@ function renderShare(data, password) { } const pb = document.getElementById('photosBlock'); - if (data.photos && data.photos.length) { + if (data.show_group_photos && data.photos && data.photos.length) { document.getElementById('photosGrid').innerHTML = data.photos.map(p => ` ${esc(p.caption || '')} @@ -150,7 +230,9 @@ function renderShare(data, password) { grid.innerHTML = '
🎈
Записей пока нет
'; return; } - const isAnonymized = data.anonymize_names; + const isAnonymized = !data.show_student_names; + const showStudentMessage = !!data.show_student_message; + const showEntryDate = !!data.show_entry_date; grid.innerHTML = data.entries.map(e => `
@@ -161,10 +243,10 @@ function renderShare(data, password) {
${!isAnonymized ? `
${esc(e.student_name)}
` : ''} ${!isAnonymized && e.group_name ? `
${esc(e.group_name)}
` : ''} -
${esc(e.description)}
+ ${showStudentMessage && e.description ? `
${esc(e.description)}
` : ''} ${filesHTML(e.files, password, currentToken)}
-
${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}
+ ${showEntryDate ? `
${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}
` : ''}
`).join(''); } @@ -179,4 +261,4 @@ document.addEventListener('click', e => { (async()=>{ await loadShare(); -})(); \ No newline at end of file +})(); diff --git a/public/links.html b/public/links.html index 1709627..1077dd9 100644 --- a/public/links.html +++ b/public/links.html @@ -58,8 +58,26 @@
+
+
+ +
+
+ +
+
+
diff --git a/public/settings.html b/public/settings.html index 522e9c0..1368cdc 100644 --- a/public/settings.html +++ b/public/settings.html @@ -43,6 +43,46 @@
+
+
+
🔗
+

Страница публичной ссылки /s/<hash>

+
+

Управление видимостью элементов на публичных страницах записей.

+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
📄
diff --git a/public/share.html b/public/share.html index b70462c..2380a4d 100644 --- a/public/share.html +++ b/public/share.html @@ -66,6 +66,15 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background .share-link-block{margin-bottom:32px} .share-link-btn{display:inline-flex;align-items:center;gap:8px;background:var(--dark);color:#fff;text-decoration:none;border-radius:999px;padding:13px 24px;font-size:.92rem;font-weight:700;transition:opacity .15s,transform .15s} .share-link-btn:hover{opacity:.9;transform:translateY(-2px)} + .countdown{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(150%);z-index:50;display:flex;align-items:center;gap:10px;padding:14px 24px;font-size:.92rem;color:var(--dark);background:#fff;border-radius:16px;box-shadow:0 8px 30px rgba(20,30,40,.16);max-width:calc(100vw - 32px);opacity:0;transition:transform .3s ease,opacity .3s ease;pointer-events:none} + .countdown.visible{transform:translateX(-50%) translateY(0);opacity:1;pointer-events:auto} + .countdown .cd-icon{font-size:1.2rem} + .countdown .cd-text{color:#4a4f5a;line-height:1.4;text-align:center} + .countdown .cd-text strong{color:var(--dark);font-weight:700} + .countdown.warn{background:#FFF3E0} + .countdown.warn .cd-text strong{color:#E65100} + .countdown.urgent{background:#FFEBEE} + .countdown.urgent .cd-text strong{color:#C62828} @@ -83,6 +92,7 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
Карточки загружаются...
+
⏳
diff --git a/server.js b/server.js index 9020243..98234fd 100644 --- a/server.js +++ b/server.js @@ -656,7 +656,7 @@ app.get('/api/settings', requireAdmin, async (_, res) => { }); app.get('/api/public-settings', apiLimiter, async (_, res) => { - const keys = ['footer_left', 'footer_right']; + const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos']; const out = {}; for (const k of keys) out[k] = await getSetting(k, ''); res.json(out); @@ -715,7 +715,8 @@ const zlib = require('zlib'); const tar = require('tar'); const os = require('os'); const AI_URL = process.env.AI_URL || 'http://text-corrector:8080'; -const AI_DEFAULT_PROMPT = 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.'; +const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf'; +const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.'; let entryAutoChecker = null; async function getAiPrompt() { @@ -730,7 +731,7 @@ async function aiCorrectText(text) { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: 'qwen2.5-1.5b-instruct', + model: AI_MODEL, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: text } @@ -1148,7 +1149,7 @@ app.get('/api/links', requireAuth, async (req, res) => { }); app.post('/api/links', requireAuth, async (req, res) => { - const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body; + const { name, group_id, student_name, date_from, date_to, expires_at, access_password } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' }); let message, linkUrl; try { @@ -1178,16 +1179,16 @@ app.post('/api/links', requireAuth, async (req, res) => { expiresAt = defaultExp.toISOString(); } const { rows } = await pool.query( - `INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password_hash, message, link_url) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, - [token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, expiresAt, passwordHash, message, linkUrl] + `INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to, show_student_names, expires_at, access_password_hash, message, link_url, show_student_message, show_entry_date, show_group_photos) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`, + [token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, req.body.show_student_names == null ? null : !!req.body.show_student_names, expiresAt, passwordHash, message, linkUrl, req.body.show_student_message == null ? null : !!req.body.show_student_message, req.body.show_entry_date == null ? null : !!req.body.show_entry_date, req.body.show_group_photos == null ? null : !!req.body.show_group_photos] ); await logAudit(req, 'link.create', { id: rows[0].id, name: name.trim() }); res.status(201).json(normDates(rows[0])); }); app.put('/api/links/:id', requireAuth, async (req, res) => { - const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body; + const { name, group_id, student_name, date_from, date_to, expires_at, access_password } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' }); let message, linkUrl; try { @@ -1227,9 +1228,9 @@ app.put('/api/links/:id', requireAuth, async (req, res) => { expiresAt = null; // Clear expiry if null sent } } - const fields = ['name = $1', 'group_id = $2', 'student_name = $3', 'date_from = $4', 'date_to = $5', 'anonymize_names = $6', 'message = $7', 'link_url = $8']; - const values = [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, message, linkUrl]; - let paramIdx = 9; + const fields = ['name = $1', 'group_id = $2', 'student_name = $3', 'date_from = $4', 'date_to = $5', 'show_student_names = $6', 'message = $7', 'link_url = $8', 'show_student_message = $9', 'show_entry_date = $10', 'show_group_photos = $11']; + const values = [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, req.body.show_student_names == null ? null : !!req.body.show_student_names, message, linkUrl, req.body.show_student_message == null ? null : !!req.body.show_student_message, req.body.show_entry_date == null ? null : !!req.body.show_entry_date, req.body.show_group_photos == null ? null : !!req.body.show_group_photos]; + let paramIdx = 12; if (passwordHash !== undefined) { fields.push(`access_password_hash = $${paramIdx++}`); values.push(passwordHash); @@ -1311,8 +1312,10 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => { } entries.forEach(r => { r.files = files[r.id] || []; }); - // Anonymize names if enabled - if (l.anonymize_names) { + const showStudentNames = l.show_student_names != null ? l.show_student_names : (await getSetting('share_show_student_names', 'true')) !== 'false'; + + // Anonymize names if disabled + if (!showStudentNames) { const nameMap = new Map(); let counter = 1; entries.forEach(e => { @@ -1342,9 +1345,13 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => { message: l.message, link_url: l.link_url, created_at: l.created_at, - anonymize_names: l.anonymize_names, + expires_at: l.expires_at, + show_student_names: showStudentNames, + show_student_message: l.show_student_message != null ? l.show_student_message : (await getSetting('share_show_student_message', 'false')) === 'true', + show_entry_date: l.show_entry_date != null ? l.show_entry_date : (await getSetting('share_show_entry_date', 'false')) === 'true', + show_group_photos: l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false', entries, - photos, + photos: (l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false') ? photos : [], }); }); @@ -2589,7 +2596,7 @@ app.get('/api/ai/queue', requireAdmin, async (_, res) => { enabled, counts, worker: entryAutoChecker ? entryAutoChecker.getStats() : null, - model: process.env.AI_MODEL || 'qwen2.5-1.5b-instruct', + model: AI_MODEL, }); }); @@ -2642,7 +2649,7 @@ app.get('/api/ai/status', requireAdmin, async (_, res) => { const service = await aiHealthCheck(); res.json({ enabled, - model: process.env.AI_MODEL || 'qwen2.5-1.5b-instruct', + model: AI_MODEL, ai_url: AI_URL, service, worker: entryAutoChecker ? entryAutoChecker.getInfo() : null, diff --git a/start-text-corrector.sh b/start-text-corrector.sh new file mode 100755 index 0000000..ef8ed73 --- /dev/null +++ b/start-text-corrector.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -e + +MODEL_NAME="${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}" +MODEL_REPO="${AI_MODEL_REPO:-Qwen/Qwen2.5-1.5B-Instruct-GGUF}" +MODEL_PATH="/models/${MODEL_NAME}" +MODEL_URL="https://huggingface.co/${MODEL_REPO}/resolve/main/${MODEL_NAME}" + +if [ ! -s "${MODEL_PATH}" ]; then + echo "[text-corrector] Model ${MODEL_PATH} not found, downloading from ${MODEL_URL}" + set -- -L --fail --retry 3 --retry-delay 5 --progress-bar -o "${MODEL_PATH}.part" + if [ -n "${HUGGINGFACE_TOKEN}" ]; then + echo "[text-corrector] Using Hugging Face token" + set -- "$@" -H "Authorization: Bearer ${HUGGINGFACE_TOKEN}" + fi + curl "$@" "${MODEL_URL}" + mv "${MODEL_PATH}.part" "${MODEL_PATH}" + echo "[text-corrector] Download complete" +fi + +echo "[text-corrector] Starting llama-server with ${MODEL_PATH}" +exec /app/llama-server -m "${MODEL_PATH}" --host 0.0.0.0 --port 8080 -c 4096 -n 256 -t 8 --temp 0.1 \ No newline at end of file diff --git a/worker.js b/worker.js index 55408ca..0727d82 100644 --- a/worker.js +++ b/worker.js @@ -6,8 +6,8 @@ const MIN_TEXT_CHARS = 4; function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) { const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080'; - const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct'; - const DEFAULT_PROMPT = defaultPrompt || 'Ты — редактор текстов. Исправь ошибки. Верни ТОЛЬКО исправленный текст.'; + const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf'; + const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.'; let started = false; let stopped = false;