feat(settings): AI model profiles management — native llama.cpp + custom OpenAI-compatible providers, default model selection, connection test
This commit is contained in:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user