feat(settings): AI model profiles management — native llama.cpp + custom OpenAI-compatible providers, default model selection, connection test
This commit is contained in:
@@ -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}
|
.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}}
|
@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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ async function loadSettings() {
|
|||||||
document.getElementById('cookieNoticeText').value = s.cookie_notice_text || '';
|
document.getElementById('cookieNoticeText').value = s.cookie_notice_text || '';
|
||||||
snapshotDirty();
|
snapshotDirty();
|
||||||
loadAiQueue();
|
loadAiQueue();
|
||||||
|
loadAiProfiles();
|
||||||
if (stRes.ok) {
|
if (stRes.ok) {
|
||||||
const st = await stRes.json();
|
const st = await stRes.json();
|
||||||
document.getElementById('dataStats').innerHTML = `
|
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 = '<span class="chip">Не удалось загрузить профили</span>'; return; }
|
||||||
|
const data = await res.json();
|
||||||
|
const profiles = data.profiles || [];
|
||||||
|
if (activeBadge) {
|
||||||
|
if (data.active === 'native') {
|
||||||
|
activeBadge.innerHTML = `По умолчанию: <b>Нативная (llama.cpp)</b> — <code>${esc(data.native.model)}</code>`;
|
||||||
|
} else {
|
||||||
|
const p = profiles.find(x => x.id === data.active);
|
||||||
|
activeBadge.innerHTML = p
|
||||||
|
? `По умолчанию: <b>${esc(p.name)}</b> — <code>${esc(p.model)}</code>`
|
||||||
|
: 'По умолчанию: нативная модель';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (select) {
|
||||||
|
select.innerHTML = `<option value="native">Нативная — ${esc(data.native.model)}</option>` +
|
||||||
|
profiles.map(p => `<option value="${esc(p.id)}">${esc(p.name)} — ${esc(p.model)}</option>`).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 => `
|
||||||
|
<div class="ai-profile-item${data.active === p.id ? ' active' : ''}">
|
||||||
|
<div class="pi-info">
|
||||||
|
<div class="pi-name">${esc(p.name)}${data.active === p.id ? ' <span class="pi-badge">по умолчанию</span>' : ''}</div>
|
||||||
|
<div class="pi-meta">${esc(p.model)} · ${esc(p.base_url)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="pi-actions">
|
||||||
|
${p.native ? '' : `<button type="button" class="pi-btn" data-edit-profile="${esc(p.id)}">Изменить</button>`}
|
||||||
|
${p.native ? '' : `<button type="button" class="pi-btn danger" data-del-profile="${esc(p.id)}" data-del-name="${esc(p.name)}">Удалить</button>`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch { listEl.innerHTML = '<span class="chip">Ошибка загрузки</span>'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
async function loadAiQueue() {
|
||||||
const el = document.getElementById('aiQueueInfo');
|
const el = document.getElementById('aiQueueInfo');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -147,6 +216,76 @@ async function loadAiQueue() {
|
|||||||
} catch { el.textContent = ''; }
|
} 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
|
||||||
|
? `<span style="color:#16a34a;font-weight:600">✓ Работает (${d.latency_ms} мс)${d.sample ? ` — «${esc(d.sample)}»` : ''}</span>`
|
||||||
|
: `<span style="color:#ef4444;font-weight:600">✗ ${esc(d.error || 'Не удалось подключиться')}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
async function saveSettings() {
|
||||||
const v = document.getElementById('spamInterval').value;
|
const v = document.getElementById('spamInterval').value;
|
||||||
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
|
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
|
||||||
|
|||||||
@@ -130,6 +130,53 @@
|
|||||||
<div class="ai-queue" id="aiQueueInfo"></div>
|
<div class="ai-queue" id="aiQueueInfo"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="boxes"></i></div>
|
||||||
|
<h3>Профили моделей ИИ</h3>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Модель, выбранная по умолчанию, обрабатывает записи учеников. «Нативная» — llama.cpp в Docker. Можно добавить любого провайдера с OpenAI-совместимым API (OpenRouter, OpenAI, Ollama и др.).</p>
|
||||||
|
<div class="ai-queue" id="aiProfileActive"></div>
|
||||||
|
<div class="card-foot" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem">
|
||||||
|
Модель по умолчанию:
|
||||||
|
<select id="aiActiveProfile" class="settings-input" style="width:auto;min-width:220px">
|
||||||
|
<option value="native">Нативная (llama.cpp)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="btn-primary ghost" id="aiProfileAddBtn" type="button"><i data-lucide="plus"></i> Добавить профиль</button>
|
||||||
|
</div>
|
||||||
|
<div id="aiProfilesList" class="ai-profiles-list"></div>
|
||||||
|
<div id="aiProfileForm" style="display:none;margin-top:14px;border:1px solid var(--border);border-radius:var(--radius);padding:16px">
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="aiProfileName">Название профиля</label>
|
||||||
|
<input type="text" id="aiProfileName" class="settings-input" placeholder="Например: OpenRouter GPT-4o-mini">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="aiProfileModel">Модель</label>
|
||||||
|
<input type="text" id="aiProfileModel" class="settings-input" placeholder="openai/gpt-4o-mini">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label for="aiProfileBaseUrl">Base URL (OpenAI-совместимый API)</label>
|
||||||
|
<input type="text" id="aiProfileBaseUrl" class="settings-input" placeholder="https://openrouter.ai/api/v1">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="aiProfileApiKey">API-ключ (если требуется)</label>
|
||||||
|
<input type="password" id="aiProfileApiKey" class="settings-input" placeholder="sk-...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:10px;margin-top:12px;flex-wrap:wrap">
|
||||||
|
<button class="btn-primary" id="aiProfileSaveBtn" type="button">Сохранить профиль</button>
|
||||||
|
<button class="btn-primary ghost" id="aiProfileTestBtn" type="button"><i data-lucide="plug-zap"></i> Проверить</button>
|
||||||
|
<button class="btn-primary ghost" id="aiProfileCancelBtn" type="button">Отмена</button>
|
||||||
|
<span id="aiProfileTestStatus" style="font-size:.85rem;align-self:center"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="database"></i></div>
|
<div class="ic"><i data-lucide="database"></i></div>
|
||||||
|
|||||||
@@ -1014,14 +1014,49 @@ async function getAiPrompt() {
|
|||||||
return prompt;
|
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) {
|
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 {
|
try {
|
||||||
const systemPrompt = await getAiPrompt();
|
const systemPrompt = await getAiPrompt();
|
||||||
const res = await fetch(`${AI_URL}/v1/chat/completions`, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: AI_MODEL,
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: text }
|
{ 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) => {
|
app.get('/api/ai/queue', requireAdmin, async (_, res) => {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status`
|
`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 };
|
const counts = { pending: 0, processing: 0, done: 0, skipped: 0, error: 0, reverted: 0 };
|
||||||
rows.forEach(r => { counts[r.ai_status] = r.n; });
|
rows.forEach(r => { counts[r.ai_status] = r.n; });
|
||||||
const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false';
|
const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false';
|
||||||
|
const activeProfile = await getActiveAiProfile();
|
||||||
res.json({
|
res.json({
|
||||||
enabled,
|
enabled,
|
||||||
counts,
|
counts,
|
||||||
worker: entryAutoChecker ? entryAutoChecker.getStats() : null,
|
worker: entryAutoChecker ? entryAutoChecker.getStats() : null,
|
||||||
model: AI_MODEL,
|
model: activeProfile ? `${activeProfile.name} (${activeProfile.model})` : AI_MODEL,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function aiHealthCheck() {
|
async function aiHealthCheck() {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
const profile = await getActiveAiProfile();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), 1500);
|
const timer = setTimeout(() => controller.abort(), 5000);
|
||||||
try {
|
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;
|
const latency_ms = Date.now() - startedAt;
|
||||||
if (!r.ok) return { reachable: false, latency_ms, error: `HTTP ${r.status}` };
|
if (!r.ok) return { reachable: false, latency_ms, error: `HTTP ${r.status}` };
|
||||||
return { reachable: true, latency_ms, error: null };
|
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 enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false';
|
||||||
const service = await aiHealthCheck();
|
const service = await aiHealthCheck();
|
||||||
|
const activeProfile = await getActiveAiProfile();
|
||||||
res.json({
|
res.json({
|
||||||
enabled,
|
enabled,
|
||||||
model: AI_MODEL,
|
model: activeProfile ? `${activeProfile.name} (${activeProfile.model})` : AI_MODEL,
|
||||||
ai_url: AI_URL,
|
ai_url: AI_URL,
|
||||||
service,
|
service,
|
||||||
worker: entryAutoChecker ? entryAutoChecker.getInfo() : null,
|
worker: entryAutoChecker ? entryAutoChecker.getInfo() : null,
|
||||||
|
|||||||
@@ -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) {
|
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 controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${AI_URL}/v1/chat/completions`, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: MODEL,
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: text },
|
{ role: 'user', content: text },
|
||||||
|
|||||||
Reference in New Issue
Block a user