feat(settings): AI model profiles management — native llama.cpp + custom OpenAI-compatible providers, default model selection, connection test

This commit is contained in:
dev
2026-09-17 11:54:08 +03:00
parent bbfde902a5
commit efe2a42d1d
5 changed files with 427 additions and 10 deletions
+12
View File
@@ -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}
+139
View File
@@ -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 = '<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() {
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
? `<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() {
const v = document.getElementById('spamInterval').value;
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
+47
View File
@@ -130,6 +130,53 @@
<div class="ai-queue" id="aiQueueInfo"></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="card-head">
<div class="ic"><i data-lucide="database"></i></div>