diff --git a/public/admin.css b/public/admin.css index a09fd5e..2d358d8 100644 --- a/public/admin.css +++ b/public/admin.css @@ -427,4 +427,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .entry-appear::after{content:'';position:absolute;inset:0;border-radius:inherit;background:linear-gradient(180deg,rgba(247,201,14,.16),transparent 55%);animation:entryFadeOut 2.4s ease .35s both;pointer-events:none} @media (prefers-reduced-motion: reduce){.entry-enter,.entry-appear,.entry-appear::before,.entry-appear::after{animation:none}} +.ai-profiles-list{margin-top:12px;display:flex;flex-direction:column;gap:8px} +.ai-profile-item{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--border);border-radius:10px;background:var(--card)} +.ai-profile-item.active{border-color:var(--accent);background:rgba(37,99,235,.05)} +.ai-profile-item .pi-info{flex:1;min-width:0} +.ai-profile-item .pi-name{font-weight:600;font-size:.9rem;display:flex;align-items:center;gap:8px} +.ai-profile-item .pi-meta{color:var(--muted);font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ai-profile-item .pi-actions{display:flex;gap:6px;flex-shrink:0} +.pi-btn{background:none;border:1px solid var(--border);border-radius:8px;padding:5px 10px;font-size:.78rem;cursor:pointer;color:var(--text);transition:all .15s} +.pi-btn:hover{border-color:var(--accent);color:var(--accent)} +.pi-btn.danger:hover{border-color:#ef4444;color:#ef4444} +.pi-badge{font-size:.68rem;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(22,163,74,.12);color:#16a34a;white-space:nowrap} + diff --git a/public/js/settings.js b/public/js/settings.js index 5df71cf..86417cd 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -54,6 +54,7 @@ async function loadSettings() { document.getElementById('cookieNoticeText').value = s.cookie_notice_text || ''; snapshotDirty(); loadAiQueue(); + loadAiProfiles(); if (stRes.ok) { const st = await stRes.json(); document.getElementById('dataStats').innerHTML = ` @@ -134,6 +135,74 @@ function renderSystemInfo(si) { `; } +async function loadAiProfiles() { + const listEl = document.getElementById('aiProfilesList'); + const activeBadge = document.getElementById('aiProfileActive'); + const select = document.getElementById('aiActiveProfile'); + if (!listEl) return; + try { + const res = await fetch(`${API}/api/ai/profiles`, { headers: hdr() }); + if (!res.ok) { listEl.innerHTML = 'Не удалось загрузить профили'; return; } + const data = await res.json(); + const profiles = data.profiles || []; + if (activeBadge) { + if (data.active === 'native') { + activeBadge.innerHTML = `По умолчанию: Нативная (llama.cpp) — ${esc(data.native.model)}`; + } else { + const p = profiles.find(x => x.id === data.active); + activeBadge.innerHTML = p + ? `По умолчанию: ${esc(p.name)} — ${esc(p.model)}` + : 'По умолчанию: нативная модель'; + } + } + if (select) { + select.innerHTML = `` + + profiles.map(p => ``).join(''); + select.value = data.active; + } + const items = [{ id: 'native', name: 'Нативная (llama.cpp в Docker)', base_url: data.native.base_url, model: data.native.model, native: true }].concat(profiles); + listEl.innerHTML = items.map(p => ` +
+
+
${esc(p.name)}${data.active === p.id ? ' по умолчанию' : ''}
+
${esc(p.model)} · ${esc(p.base_url)}
+
+
+ ${p.native ? '' : ``} + ${p.native ? '' : ``} +
+
+ `).join(''); + } catch { listEl.innerHTML = 'Ошибка загрузки'; } +} + +let aiProfileEditingId = null; + +function openAiProfileForm(profile) { + aiProfileEditingId = profile ? profile.id : null; + document.getElementById('aiProfileForm').style.display = 'block'; + document.getElementById('aiProfileName').value = profile ? profile.name : ''; + document.getElementById('aiProfileModel').value = profile ? profile.model : ''; + document.getElementById('aiProfileBaseUrl').value = profile ? profile.base_url : ''; + document.getElementById('aiProfileApiKey').value = ''; + document.getElementById('aiProfileTestStatus').textContent = ''; + document.getElementById('aiProfileName').focus(); +} + +function closeAiProfileForm() { + aiProfileEditingId = null; + document.getElementById('aiProfileForm').style.display = 'none'; +} + +function aiProfileFormData() { + return { + name: document.getElementById('aiProfileName').value, + model: document.getElementById('aiProfileModel').value, + base_url: document.getElementById('aiProfileBaseUrl').value, + api_key: document.getElementById('aiProfileApiKey').value, + }; +} + async function loadAiQueue() { const el = document.getElementById('aiQueueInfo'); if (!el) return; @@ -147,6 +216,76 @@ async function loadAiQueue() { } catch { el.textContent = ''; } } +async function saveAiProfile() { + const body = aiProfileFormData(); + const res = await fetch(aiProfileEditingId ? `${API}/api/ai/profiles/${aiProfileEditingId}` : `${API}/api/ai/profiles`, { + method: aiProfileEditingId ? 'PUT' : 'POST', + headers: hdrJson(), + body: JSON.stringify(body), + }); + if (res.ok) { + showToast(aiProfileEditingId ? 'Профиль обновлён' : 'Профиль добавлен'); + closeAiProfileForm(); + loadAiProfiles(); + } else { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Ошибка сохранения'); + } +} + +async function testAiProfile() { + const st = document.getElementById('aiProfileTestStatus'); + st.textContent = 'Проверка…'; + const res = await fetch(`${API}/api/ai/profiles/test`, { method: 'POST', headers: hdrJson(), body: JSON.stringify(aiProfileFormData()) }); + const d = await res.json().catch(() => ({ ok: false, error: 'Ошибка' })); + st.innerHTML = d.ok + ? `✓ Работает (${d.latency_ms} мс)${d.sample ? ` — «${esc(d.sample)}»` : ''}` + : `✗ ${esc(d.error || 'Не удалось подключиться')}`; +} + +async function activateAiProfile(id) { + const res = await fetch(`${API}/api/ai/profiles/activate`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ id }) }); + if (res.ok) { + showToast('Модель по умолчанию обновлена'); + loadAiProfiles(); + loadAiQueue(); + } else { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Ошибка'); + } +} + +async function deleteAiProfile(id, name) { + if (!confirm(`Удалить профиль «${name}»?`)) return; + const res = await fetch(`${API}/api/ai/profiles/${id}`, { method: 'DELETE', headers: hdr() }); + if (res.ok) { + showToast('Профиль удалён'); + loadAiProfiles(); + } else { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Ошибка удаления'); + } +} + +document.getElementById('aiProfileAddBtn').addEventListener('click', () => openAiProfileForm(null)); +document.getElementById('aiProfileSaveBtn').addEventListener('click', saveAiProfile); +document.getElementById('aiProfileTestBtn').addEventListener('click', testAiProfile); +document.getElementById('aiProfileCancelBtn').addEventListener('click', closeAiProfileForm); +document.getElementById('aiActiveProfile').addEventListener('change', e => activateAiProfile(e.target.value)); +document.getElementById('aiProfilesList').addEventListener('click', e => { + const del = e.target.closest('[data-del-profile]'); + if (del) { deleteAiProfile(del.dataset.delProfile, del.dataset.delName); return; } + const edit = e.target.closest('[data-edit-profile]'); + if (edit) { + fetch(`${API}/api/ai/profiles`, { headers: hdr() }) + .then(r => r.json()) + .then(data => { + const p = (data.profiles || []).find(x => x.id === edit.dataset.editProfile); + if (p) openAiProfileForm(p); + }); + } +}); + async function saveSettings() { const v = document.getElementById('spamInterval').value; if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; } diff --git a/public/settings.html b/public/settings.html index 501afbb..290f05a 100644 --- a/public/settings.html +++ b/public/settings.html @@ -130,6 +130,53 @@
+
+
+
+

Профили моделей ИИ

+
+

Модель, выбранная по умолчанию, обрабатывает записи учеников. «Нативная» — llama.cpp в Docker. Можно добавить любого провайдера с OpenAI-совместимым API (OpenRouter, OpenAI, Ollama и др.).

+
+
+ + +
+
+ +
+
diff --git a/server.js b/server.js index c68c249..5ccbf6c 100644 --- a/server.js +++ b/server.js @@ -1014,14 +1014,49 @@ async function getAiPrompt() { return prompt; } +async function getAiProfiles() { + try { + const raw = await getSetting('ai_profiles', '[]'); + const list = JSON.parse(raw || '[]'); + return Array.isArray(list) ? list.filter(p => p && p.id && p.base_url && p.model) : []; + } catch (e) { + return []; + } +} + +async function getActiveAiProfile() { + const active = await getSetting('ai_active_profile', 'native'); + if (!active || active === 'native') return null; + const profiles = await getAiProfiles(); + return profiles.find(p => p.id === active) || null; +} + +function normalizeOpenAiBase(base) { + let b = String(base || '').trim().replace(/\/+$/, ''); + if (!/^https?:\/\//i.test(b)) return null; + if (!/\/v1$/i.test(b)) b += '/v1'; + return b; +} + async function aiCorrectText(text) { + const profile = await getActiveAiProfile(); + let url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`; + let model = AI_MODEL; + const headers = { 'Content-Type': 'application/json' }; + if (profile) { + const base = normalizeOpenAiBase(profile.base_url); + if (!base) throw new Error('Некорректный base_url профиля ИИ'); + url = `${base}/chat/completions`; + model = profile.model; + if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`; + } try { const systemPrompt = await getAiPrompt(); - const res = await fetch(`${AI_URL}/v1/chat/completions`, { + const res = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, body: JSON.stringify({ - model: AI_MODEL, + model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: text } @@ -3592,6 +3627,142 @@ app.post('/api/ai/correct', requireAuth, async (req, res) => { } }); +// --- AI model profiles --- +function validateProfileBody(body) { + const name = String(body?.name || '').trim(); + const base = String(body?.base_url || '').trim().replace(/\/+$/, ''); + const model = String(body?.model || '').trim(); + const apiKey = String(body?.api_key || '').trim(); + 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 }; +} + +app.get('/api/ai/profiles', requireAdmin, async (_, res) => { + const profiles = await getAiProfiles(); + const active = await getSetting('ai_active_profile', 'native'); + res.json({ + active, + native: { id: 'native', name: 'Нативная (llama.cpp в Docker)', base_url: AI_URL, model: AI_MODEL }, + profiles, + }); +}); + +app.post('/api/ai/profiles', requireAdmin, async (req, res) => { + const v = validateProfileBody(req.body); + if (v.error) return res.status(400).json({ error: v.error }); + const profiles = await getAiProfiles(); + if (profiles.length >= 20) return res.status(400).json({ error: 'Слишком много профилей (макс. 20)' }); + const profile = { id: crypto.randomBytes(8).toString('hex'), ...v }; + profiles.push(profile); + await pool.query( + `INSERT INTO settings (key, value) VALUES ('ai_profiles', $1) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [JSON.stringify(profiles)] + ); + await logAudit(req, 'ai.profile.create', { id: profile.id, name: profile.name, model: profile.model }); + invalidateSettings(); + if (entryAutoChecker) entryAutoChecker.notify(); + res.status(201).json(profile); +}); + +app.put('/api/ai/profiles/:id', requireAdmin, async (req, res) => { + const profiles = await getAiProfiles(); + const idx = profiles.findIndex(p => p.id === req.params.id); + if (idx === -1) return res.status(404).json({ error: 'Профиль не найден' }); + const v = validateProfileBody(req.body); + if (v.error) return res.status(400).json({ error: v.error }); + profiles[idx] = { id: req.params.id, ...v }; + await pool.query( + `INSERT INTO settings (key, value) VALUES ('ai_profiles', $1) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [JSON.stringify(profiles)] + ); + await logAudit(req, 'ai.profile.update', { id: req.params.id, name: v.name, model: v.model }); + invalidateSettings(); + if (entryAutoChecker) entryAutoChecker.notify(); + res.json(profiles[idx]); +}); + +app.delete('/api/ai/profiles/:id', requireAdmin, async (req, res) => { + const profiles = await getAiProfiles(); + const idx = profiles.findIndex(p => p.id === req.params.id); + if (idx === -1) return res.status(404).json({ error: 'Профиль не найден' }); + const removed = profiles.splice(idx, 1)[0]; + await pool.query( + `INSERT INTO settings (key, value) VALUES ('ai_profiles', $1) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [JSON.stringify(profiles)] + ); + const active = await getSetting('ai_active_profile', 'native'); + if (active === req.params.id) { + await pool.query( + `INSERT INTO settings (key, value) VALUES ('ai_active_profile', 'native') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value` + ); + } + await logAudit(req, 'ai.profile.delete', { id: req.params.id, name: removed.name }); + invalidateSettings(); + if (entryAutoChecker) entryAutoChecker.notify(); + res.json({ ok: true }); +}); + +app.post('/api/ai/profiles/activate', requireAdmin, async (req, res) => { + const id = String(req.body?.id || '').trim(); + if (id !== 'native') { + const profiles = await getAiProfiles(); + if (!profiles.some(p => p.id === id)) return res.status(400).json({ error: 'Профиль не найден' }); + } + await pool.query( + `INSERT INTO settings (key, value) VALUES ('ai_active_profile', $1) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [id] + ); + await logAudit(req, 'ai.profile.activate', { id }); + invalidateSettings(); + if (entryAutoChecker) entryAutoChecker.notify(); + res.json({ ok: true, active: id }); +}); + +app.post('/api/ai/profiles/test', requireAdmin, async (req, res) => { + const v = validateProfileBody(req.body); + if (v.error) return res.status(400).json({ error: v.error }); + const base = normalizeOpenAiBase(v.base_url); + const startedAt = Date.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15000); + try { + const headers = { 'Content-Type': 'application/json' }; + if (v.api_key) headers.Authorization = `Bearer ${v.api_key}`; + const r = await fetch(`${base}/chat/completions`, { + method: 'POST', + headers, + signal: controller.signal, + body: JSON.stringify({ + model: v.model, + messages: [{ role: 'user', content: 'Ответь одним словом: ок' }], + max_tokens: 8, + temperature: 0, + }), + }); + const latency_ms = Date.now() - startedAt; + if (!r.ok) { + const t = await r.text().catch(() => ''); + return res.json({ ok: false, latency_ms, error: `HTTP ${r.status}${t ? ': ' + t.slice(0, 200) : ''}` }); + } + const d = await r.json(); + const sample = (d.choices?.[0]?.message?.content || '').trim(); + res.json({ ok: true, latency_ms, sample: sample.slice(0, 120) }); + } catch (e) { + const latency_ms = Date.now() - startedAt; + res.json({ ok: false, latency_ms, error: e.name === 'AbortError' ? 'Таймаут 15 с' : (e.message || 'unreachable') }); + } finally { + clearTimeout(timer); + } +}); + app.get('/api/ai/queue', requireAdmin, async (_, res) => { const { rows } = await pool.query( `SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status` @@ -3599,20 +3770,32 @@ app.get('/api/ai/queue', requireAdmin, async (_, res) => { const counts = { pending: 0, processing: 0, done: 0, skipped: 0, error: 0, reverted: 0 }; rows.forEach(r => { counts[r.ai_status] = r.n; }); const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false'; + const activeProfile = await getActiveAiProfile(); res.json({ enabled, counts, worker: entryAutoChecker ? entryAutoChecker.getStats() : null, - model: AI_MODEL, + model: activeProfile ? `${activeProfile.name} (${activeProfile.model})` : AI_MODEL, }); }); async function aiHealthCheck() { const startedAt = Date.now(); + const profile = await getActiveAiProfile(); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 1500); + const timer = setTimeout(() => controller.abort(), 5000); try { - const r = await fetch(`${AI_URL}/health`, { signal: controller.signal }); + const headers = {}; + let url; + if (profile) { + const base = normalizeOpenAiBase(profile.base_url); + if (!base) return { reachable: false, latency_ms: 0, error: 'Некорректный base_url профиля' }; + url = `${base}/models`; + if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`; + } else { + url = `${AI_URL}/health`; + } + const r = await fetch(url, { headers, signal: controller.signal }); const latency_ms = Date.now() - startedAt; if (!r.ok) return { reachable: false, latency_ms, error: `HTTP ${r.status}` }; return { reachable: true, latency_ms, error: null }; @@ -3655,9 +3838,10 @@ app.get('/api/ai/status', requireAdmin, async (_, res) => { ]); const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false'; const service = await aiHealthCheck(); + const activeProfile = await getActiveAiProfile(); res.json({ enabled, - model: AI_MODEL, + model: activeProfile ? `${activeProfile.name} (${activeProfile.model})` : AI_MODEL, ai_url: AI_URL, service, worker: entryAutoChecker ? entryAutoChecker.getInfo() : null, diff --git a/worker.js b/worker.js index f7b3806..2a0e32b 100644 --- a/worker.js +++ b/worker.js @@ -70,16 +70,51 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm } } + async function resolveActiveProfile() { + try { + const active = await getSetting('ai_active_profile', 'native'); + if (active && active !== 'native') { + const raw = await getSetting('ai_profiles', '[]'); + const list = JSON.parse(raw || '[]'); + if (Array.isArray(list)) { + return list.find(p => p && p.id === active && p.base_url && p.model) || null; + } + } + } catch (e) {} + return null; + } + + function normalizeOpenAiBase(base) { + let b = String(base || '').trim().replace(/\/+$/, ''); + if (!/^https?:\/\//i.test(b)) return null; + if (!/\/v1$/i.test(b)) b += '/v1'; + return b; + } + async function callModel(text, systemPrompt) { + const profile = await resolveActiveProfile(); + const headers = { 'Content-Type': 'application/json' }; + let url; + let model; + if (profile) { + const base = normalizeOpenAiBase(profile.base_url); + if (!base) throw new Error('Некорректный base_url профиля ИИ'); + url = `${base}/chat/completions`; + model = profile.model; + if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`; + } else { + url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`; + model = MODEL; + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { - const res = await fetch(`${AI_URL}/v1/chat/completions`, { + const res = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, signal: controller.signal, body: JSON.stringify({ - model: MODEL, + model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: text },