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
@@ -62,6 +62,7 @@
|
||||
' <label><span>Списки файлов</span><select data-pref="compact" data-bool><option value="">обычные</option><option value="1">компактные</option></select></label>' +
|
||||
' <label><span>Время изменения файлов</span><select data-pref="fileTime"><option value="">показывать</option><option value="hide">скрыть</option></select></label>' +
|
||||
' <label><span>Анимации</span><select data-pref="motion"><option value="">включены</option><option value="off">выключены</option></select></label>' +
|
||||
' <label><span>Выбор модели в чате</span><select data-pref="aiModelPick"><option value="">когда моделей несколько</option><option value="always">всегда</option></select></label>' +
|
||||
' <label><span>«Здесь» в меню «+»</span><select data-pref="openHere"><option value="">в папке активной вкладки</option><option value="off">в домашней папке</option></select></label>' +
|
||||
' </div></details>' +
|
||||
' <div class="acct-keysec">' +
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -844,7 +844,7 @@ dialog.machinedlg { max-width:1000px; width:94vw; max-height:90vh; padding:0; ov
|
||||
display:inline-flex; align-items:center; justify-content:center; box-shadow:0 8px 24px rgba(0,0,0,.45); }
|
||||
.ai-fab[hidden] { display:none; }
|
||||
body.touch.term-active .ai-fab { bottom:104px; }
|
||||
.ai-box { position:fixed; right:28px; bottom:96px; z-index:41; width:360px; max-width:calc(100vw - 24px); height:min(500px, calc(100vh - 120px));
|
||||
.ai-box { position:fixed; right:28px; bottom:96px; z-index:41; width:384px; max-width:calc(100vw - 24px); height:min(500px, calc(100vh - 120px));
|
||||
display:flex; flex-direction:column; background:var(--panel); border:1px solid var(--border); border-radius:12px;
|
||||
box-shadow:0 18px 44px rgba(0,0,0,.55); overflow:hidden; transform-origin:bottom right;
|
||||
opacity:0; visibility:hidden; pointer-events:none; transform:translateY(14px) scale(.96);
|
||||
@@ -957,8 +957,33 @@ dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560p
|
||||
.ai-msg.result pre { margin:6px 0 2px; max-height:220px; overflow:auto; white-space:pre-wrap; font-size:12px; }
|
||||
.ai-cap { font-size:12px; margin-bottom:4px; }
|
||||
/* Modes as pills: on - lit in the accent, off - a quiet outline. */
|
||||
.ai-pills { display:flex; gap:6px; padding:8px 10px 0; border-top:1px solid var(--border); }
|
||||
.ai-pill { display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 12px; border-radius:999px; font-size:12.5px; font-weight:500;
|
||||
.ai-pills { display:flex; align-items:center; gap:6px; padding:8px 10px 0; border-top:1px solid var(--border); }
|
||||
.ai-pills .ai-pill { flex:none; }
|
||||
/* The model: its name and a chevron at the right of the pills, quiet until
|
||||
pointed at; the list opens upwards, as a small card. */
|
||||
.ai-mpick { position:relative; margin-left:auto; min-width:0; }
|
||||
.ai-mpick[hidden] { display:none; }
|
||||
.ai-mbtn { display:inline-flex; align-items:center; gap:3px; max-width:100%; height:28px; padding:0 6px 0 9px; border:0; border-radius:8px;
|
||||
background:transparent; color:var(--muted); font-size:12.5px; font-weight:500; transition:background-color .15s, color .15s; }
|
||||
.ai-mbtn:hover, .ai-mpick.open .ai-mbtn { color:var(--text); background:#1b222c; }
|
||||
.ai-mbtn:focus-visible { outline:none; box-shadow:0 0 0 2px rgba(79,157,255,.45); }
|
||||
.ai-mname { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ai-mchev { flex:none; opacity:.8; transition:transform .2s ease; }
|
||||
.ai-mpick.open .ai-mchev { transform:rotate(180deg); }
|
||||
.ai-mmenu { position:absolute; right:0; bottom:calc(100% + 8px); z-index:6; min-width:210px; max-width:280px; max-height:260px; overflow:auto;
|
||||
display:grid; gap:2px; padding:5px; background:var(--panel); border:1px solid var(--border); border-radius:12px;
|
||||
box-shadow:0 12px 32px rgba(0,0,0,.5); transform-origin:bottom right;
|
||||
opacity:0; visibility:hidden; transform:translateY(6px) scale(.98); pointer-events:none;
|
||||
transition:opacity .16s ease, transform .16s ease, visibility 0s linear .16s; }
|
||||
.ai-mmenu::before { content:'Модель'; padding:5px 10px 4px; font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); }
|
||||
.ai-mpick.open .ai-mmenu { opacity:1; visibility:visible; transform:none; pointer-events:auto; transition:opacity .16s ease, transform .16s ease; }
|
||||
.ai-mitem { display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; padding:8px 10px; border:0; border-radius:8px;
|
||||
background:transparent; color:var(--text); font-size:13.5px; font-weight:450; text-align:left; }
|
||||
.ai-mitem:hover, .ai-mitem:focus-visible { outline:none; background:#1b222c; }
|
||||
.ai-mitem-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ai-mcheck { flex:none; display:flex; color:var(--accent); visibility:hidden; }
|
||||
.ai-mitem[aria-selected="true"] .ai-mcheck { visibility:visible; }
|
||||
.ai-pill { display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 10px; border-radius:999px; font-size:12.5px; font-weight:500;
|
||||
background:transparent; color:var(--muted); border:1px solid var(--border); transition:background .18s, color .18s, border-color .18s; }
|
||||
.ai-pill:hover { color:var(--text); border-color:#3a4554; }
|
||||
.ai-pill[aria-pressed="true"] { background:rgba(91,140,255,.16); color:#9db8ff; border-color:rgba(91,140,255,.55); }
|
||||
@@ -1097,6 +1122,13 @@ body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-mot
|
||||
.fp-ring .fg { stroke:var(--accent); stroke-linecap:round; stroke-dasharray:100; stroke-dashoffset:calc(100 - var(--up, 0)); transition:stroke-dashoffset .25s ease; }
|
||||
/* Настройки › ИИ-ассистент › поиск: the fields of the engine chosen. */
|
||||
.ai-form .ai-sub { margin:10px 0 0; font-size:15px; }
|
||||
/* Настройки: the models, a card each. */
|
||||
.ai-models { display:grid; gap:10px; }
|
||||
.ai-model { display:grid; gap:10px; min-width:0; margin:0; padding:12px; border:1px solid var(--border); border-radius:10px; }
|
||||
.ai-model-head { display:flex; align-items:flex-end; gap:8px; margin:12px 0 4px; }
|
||||
.ai-model-head .ai-model-name { flex:1; min-width:0; margin:0; }
|
||||
.ai-model-del { flex:none; height:38px; }
|
||||
.ai-models .ai-model:only-child .ai-model-del { display:none; }
|
||||
.ai-form:not([data-search="searxng"]) .s-url { display:none; }
|
||||
.ai-form:not([data-search="brave"]):not([data-search="tavily"]) .s-key { display:none; }
|
||||
/* A question about an action: where, the command in mono, a note. */
|
||||
|
||||
@@ -23,6 +23,20 @@
|
||||
}, e => alert('Не скопировалось: ' + e.message));
|
||||
});
|
||||
if (window.UsersTable) window.UsersTable.mount(root);
|
||||
// ИИ-ассистент: a card per model, added from the blank one and taken
|
||||
// away by its «Удалить» (the last one stays).
|
||||
const list = root.querySelector('.ai-models'), blank = root.querySelector('template.ai-model-new');
|
||||
if (list && blank) {
|
||||
root.querySelector('.ai-model-add').onclick = () => {
|
||||
const card = blank.content.firstElementChild.cloneNode(true);
|
||||
list.appendChild(card);
|
||||
card.querySelector('[name=m_name]').focus();
|
||||
};
|
||||
list.addEventListener('click', ev => {
|
||||
const del = ev.target.closest('.ai-model-del');
|
||||
if (del && list.children.length > 1) del.closest('.ai-model').remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
let p = Object.assign({}, (window.APP && window.APP.prefs) || {});
|
||||
function apply() {
|
||||
const b = document.body;
|
||||
// Parts that follow a setting of their own (the chat's model menu).
|
||||
document.dispatchEvent(new CustomEvent('lpra:prefs'));
|
||||
b.dataset.tabicons = p.tabIcons || 'left';
|
||||
b.classList.toggle('compact', !!p.compact);
|
||||
b.classList.toggle('no-ftime', p.fileTime === 'hide');
|
||||
|
||||
Reference in new issue
Block a user