feat: several models for the assistant, each with its own name, kind, address and key; a model menu in the chat when there are several (or always, from Аккаунт › Вид), the choice kept for the user
This commit is contained in:
1 parent
ded86e1c3f
commit
983809714b
12 files changed
+411
-44
No files matched your search
@@ -34,6 +34,10 @@
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>Поиск</button>' +
|
||||
'<button type="button" class="ai-pill" data-mode="think" aria-pressed="false" title="Сначала обдумывает, потом отвечает: дольше, но точнее">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.3h6c0-1 .4-1.8 1-2.3A7 7 0 0 0 12 2z"/></svg>Думать</button>' +
|
||||
// The model, when there is a choice: its name; a click opens the list.
|
||||
'<div class="ai-mpick" hidden><button type="button" class="ai-mbtn" aria-haspopup="listbox" aria-expanded="false" title="Модель">' +
|
||||
'<span class="ai-mname"></span><svg class="ai-mchev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></button>' +
|
||||
'<div class="ai-mmenu" role="listbox" aria-label="Модель"></div></div>' +
|
||||
'</div>' +
|
||||
'<form class="ai-form-in"><input type="file" class="ai-file-input" multiple accept="image/*,audio/*,video/*,.txt,.md,.csv,.tsv,.json,.xml,.doc,.docx,.xlsx,.ods,.odt,.pdf,.mkv,.avi,.3gp,.tif,.tiff" hidden>' +
|
||||
'<div class="ai-inputbox"><div class="ai-attachments" hidden></div><div class="ai-entry">' +
|
||||
@@ -73,6 +77,7 @@
|
||||
const isOpen = () => box.classList.contains('on');
|
||||
function show() {
|
||||
loadTalk();
|
||||
loadState();
|
||||
placeBox();
|
||||
box.classList.add('on');
|
||||
fab.classList.add('away');
|
||||
@@ -102,7 +107,12 @@
|
||||
window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) shut(); });
|
||||
function setOn(on) { fab.hidden = !on; if (!on) hide(); }
|
||||
const desk = !matchMedia('(pointer: coarse)').matches;
|
||||
fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {});
|
||||
// The models to choose from come with the state, again on every opening
|
||||
// (an admin may have changed them).
|
||||
let models = [];
|
||||
const loadState = () => fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json())
|
||||
.then(d => { setOn(!!d.on); models = d.models || []; drawModels(); }).catch(() => {});
|
||||
loadState();
|
||||
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
|
||||
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
|
||||
|
||||
@@ -652,7 +662,7 @@
|
||||
await loadMachines();
|
||||
const r = await fetch('/api/panel/ai/chat', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal,
|
||||
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId, attachments: files || []})
|
||||
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId, attachments: files || [], model: modelId()})
|
||||
});
|
||||
// A new chat gets its id (and its name) from the answer.
|
||||
const cid = Number(r.headers.get('X-Chat-Id')) || 0;
|
||||
@@ -688,6 +698,59 @@
|
||||
const agentOn = () => pill('agent').getAttribute('aria-pressed') === 'true';
|
||||
const thinkOn = () => pill('think').getAttribute('aria-pressed') === 'true';
|
||||
const searchOn = () => pill('search').getAttribute('aria-pressed') === 'true';
|
||||
// ---------- the model ----------
|
||||
// Shown when there are several models, or always if the user asked for it
|
||||
// (Аккаунт › Вид); the choice is the user's, kept on the server.
|
||||
const mpick = q('.ai-mpick'), mbtn = q('.ai-mbtn'), mmenu = q('.ai-mmenu');
|
||||
const CHECK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
|
||||
function modelId() {
|
||||
const want = window.Prefs ? Prefs.get().aiModel : '';
|
||||
return (models.find(m => m.id === want) || models[0] || {}).id || '';
|
||||
}
|
||||
function drawModels() {
|
||||
const always = window.Prefs && Prefs.get().aiModelPick === 'always';
|
||||
mpick.hidden = !(models.length > 1 || (always && models.length));
|
||||
const cur = modelId(), m = models.find(x => x.id === cur);
|
||||
q('.ai-mname').textContent = m ? m.name : '';
|
||||
mbtn.title = m ? 'Модель: ' + m.name : 'Модель';
|
||||
mmenu.innerHTML = '';
|
||||
models.forEach(x => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'ai-mitem'; b.setAttribute('role', 'option');
|
||||
b.setAttribute('aria-selected', String(x.id === cur));
|
||||
b.innerHTML = '<span class="ai-mitem-name"></span><span class="ai-mcheck">' + CHECK + '</span>';
|
||||
b.querySelector('.ai-mitem-name').textContent = x.name;
|
||||
b.onclick = () => {
|
||||
closeModels();
|
||||
if (window.Prefs) Prefs.set('aiModel', x.id);
|
||||
drawModels();
|
||||
input.focus();
|
||||
};
|
||||
mmenu.appendChild(b);
|
||||
});
|
||||
}
|
||||
function openModels() {
|
||||
mpick.classList.add('open');
|
||||
mbtn.setAttribute('aria-expanded', 'true');
|
||||
const sel = mmenu.querySelector('[aria-selected="true"]') || mmenu.firstChild;
|
||||
if (sel) sel.focus();
|
||||
}
|
||||
function closeModels() {
|
||||
mpick.classList.remove('open');
|
||||
mbtn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
mbtn.onclick = () => mpick.classList.contains('open') ? closeModels() : openModels();
|
||||
document.addEventListener('pointerdown', ev => { if (!mpick.contains(ev.target)) closeModels(); }, true);
|
||||
mpick.addEventListener('keydown', ev => {
|
||||
if (ev.key === 'Escape' && mpick.classList.contains('open')) { ev.stopPropagation(); closeModels(); mbtn.focus(); return; }
|
||||
if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
|
||||
ev.preventDefault();
|
||||
if (!mpick.classList.contains('open')) { openModels(); return; }
|
||||
const items = [...mmenu.children], i = items.indexOf(document.activeElement);
|
||||
const n = items[(i + (ev.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length];
|
||||
if (n) n.focus();
|
||||
});
|
||||
document.addEventListener('lpra:prefs', drawModels);
|
||||
// The modes are kept in this browser.
|
||||
box.querySelectorAll('.ai-pill').forEach(b => {
|
||||
try { b.setAttribute('aria-pressed', String(localStorage.getItem('lpra.ai.' + b.dataset.mode) === '1')); } catch (e) { /* off */ }
|
||||
|
||||
Reference in new issue
Block a user