feat: the assistant's chat slides open and shut, smaller on a computer, a screen of its own on a phone that «назад» closes; a sparkle button
This commit is contained in:
1 parent
94bf292494
commit
d9c7c2dd42
3 files changed
+35
-11
No files matched your search
@@ -15,10 +15,9 @@
|
||||
fab.hidden = true;
|
||||
fab.title = 'ИИ-ассистент';
|
||||
fab.setAttribute('aria-label', 'ИИ-ассистент');
|
||||
fab.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z"/><path d="M8.5 11h.01M12 11h.01M15.5 11h.01"/></svg>';
|
||||
fab.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M10 2.5c.3 0 .5.2.6.5l1 3.7a5 5 0 0 0 3.6 3.6l3.7 1c.6.2.6 1 0 1.2l-3.7 1a5 5 0 0 0-3.6 3.6l-1 3.7c-.2.6-1 .6-1.2 0l-1-3.7a5 5 0 0 0-3.6-3.6l-3.7-1c-.6-.2-.6-1 0-1.2l3.7-1a5 5 0 0 0 3.6-3.6l1-3.7c.1-.3.3-.5.6-.5z"/><path d="M18.5 1.5c.2 0 .3.1.4.3l.4 1.4c.2.6.7 1.1 1.3 1.3l1.4.4c.4.1.4.6 0 .8l-1.4.4c-.6.2-1.1.7-1.3 1.3l-.4 1.4c-.1.4-.7.4-.8 0l-.4-1.4a2 2 0 0 0-1.3-1.3l-1.4-.4c-.4-.2-.4-.7 0-.8l1.4-.4c.6-.2 1.1-.7 1.3-1.3l.4-1.4c.1-.2.2-.3.4-.3z" opacity=".75"/></svg>';
|
||||
const box = document.createElement('div');
|
||||
box.className = 'ai-box';
|
||||
box.hidden = true;
|
||||
box.innerHTML =
|
||||
'<div class="ai-head"><b>Ассистент</b><span class="muted ai-where"></span><span class="spacer"></span>' +
|
||||
'<button type="button" class="btn ghost small ai-clear" title="Новый разговор">Очистить</button>' +
|
||||
@@ -32,11 +31,27 @@
|
||||
const talk = []; // {role, content}
|
||||
let ac = null;
|
||||
|
||||
function setOn(on) { fab.hidden = !on; if (!on) box.hidden = true; }
|
||||
// Open and shut with a short slide. On a phone the chat takes the screen
|
||||
// and is one step of history: «назад» shuts it.
|
||||
const phone = () => matchMedia('(max-width: 640px)').matches;
|
||||
const isOpen = () => box.classList.contains('on');
|
||||
function show() {
|
||||
box.classList.add('on');
|
||||
if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href);
|
||||
where();
|
||||
if (!phone()) input.focus();
|
||||
}
|
||||
function hide() {
|
||||
if (!isOpen()) return;
|
||||
if (history.state && history.state.aiChat) { history.back(); return; }
|
||||
box.classList.remove('on');
|
||||
}
|
||||
window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) box.classList.remove('on'); });
|
||||
function setOn(on) { fab.hidden = !on; if (!on) hide(); }
|
||||
fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {});
|
||||
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
|
||||
fab.onclick = () => { box.hidden = !box.hidden; if (!box.hidden) { where(); input.focus(); } };
|
||||
q('.ai-close').onclick = () => { box.hidden = true; };
|
||||
fab.onclick = () => (isOpen() ? hide() : show());
|
||||
q('.ai-close').onclick = hide;
|
||||
q('.ai-clear').onclick = () => {
|
||||
if (ac) ac.abort();
|
||||
talk.length = 0;
|
||||
|
||||
@@ -824,10 +824,14 @@ 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:18px; bottom:82px; z-index:41; width:400px; max-width:calc(100vw - 24px); height:min(600px, calc(100vh - 120px));
|
||||
.ai-box { position:fixed; right:18px; bottom:82px; z-index:41; width:360px; 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; }
|
||||
.ai-box[hidden] { display:none; }
|
||||
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);
|
||||
transition:opacity .22s ease, transform .22s ease, visibility 0s linear .22s; }
|
||||
.ai-box.on { opacity:1; visibility:visible; pointer-events:auto; transform:none; transition:opacity .22s ease, transform .22s ease; }
|
||||
.ai-fab { transition:transform .2s ease, box-shadow .2s ease; background:linear-gradient(135deg, #5b8cff, #8b5cf6); border:0; color:#fff; }
|
||||
.ai-fab:hover { transform:scale(1.06); box-shadow:0 10px 28px rgba(91,140,255,.45); }
|
||||
.ai-head { display:flex; align-items:center; gap:8px; padding:10px 12px; border-bottom:1px solid var(--border); }
|
||||
.ai-head .ai-where { font-size:12px; }
|
||||
.ai-list { flex:1; overflow:auto; padding:12px; display:flex; flex-direction:column; gap:10px; }
|
||||
@@ -843,8 +847,9 @@ body.touch.term-active .ai-fab { bottom:104px; }
|
||||
background:#0c0f13; border:1px solid var(--border); border-radius:8px; }
|
||||
.ai-form-in textarea:focus { outline:none; border-color:var(--accent); }
|
||||
@media (max-width: 640px) {
|
||||
.ai-box { right:8px; left:8px; width:auto; max-width:none; bottom:78px; height:calc(100dvh - 150px); }
|
||||
body.touch.term-active .ai-box { bottom:164px; height:calc(100dvh - 236px); }
|
||||
/* A phone: the chat is a screen of its own, sliding up. */
|
||||
.ai-box { inset:0; width:auto; max-width:none; height:auto; border-radius:0; border:0; z-index:60; transform:translateY(40px); }
|
||||
.ai-box.on { transform:none; }
|
||||
}
|
||||
.ai-send { width:40px; height:40px; padding:0; flex:none; display:inline-flex; align-items:center; justify-content:center; border-radius:8px; }
|
||||
/* The panel's own confirm and prompt. */
|
||||
|
||||
@@ -61,11 +61,15 @@
|
||||
$('main#app').dataset.view = view;
|
||||
document.dispatchEvent(new CustomEvent('app:view', {detail: view}));
|
||||
}
|
||||
// The address the shell shows; a history step that keeps it (a chat on a
|
||||
// phone closing by «назад») moves no view.
|
||||
let shown = location.pathname + location.search;
|
||||
async function go(url, push = true) {
|
||||
const u = new URL(url, location.href);
|
||||
const view = viewOf(u.pathname);
|
||||
if (!view || u.origin !== location.origin) { location.href = u.href; return; }
|
||||
if (push && u.pathname + u.search !== location.pathname + location.search) history.pushState({}, '', u.pathname + u.search + u.hash);
|
||||
shown = u.pathname + u.search;
|
||||
show(view);
|
||||
if (view !== 'terminal') await load($('.view[data-view="' + view + '"]'), u.pathname + u.search);
|
||||
}
|
||||
@@ -77,7 +81,7 @@
|
||||
const u = new URL(a.href, location.href);
|
||||
if (u.origin === location.origin && viewOf(u.pathname)) { ev.preventDefault(); go(u.href); }
|
||||
});
|
||||
window.addEventListener('popstate', () => go(location.href, false));
|
||||
window.addEventListener('popstate', () => { if (location.pathname + location.search !== shown) go(location.href, false); });
|
||||
|
||||
// A form in a fragment is sent from here: the answer's address says what
|
||||
// to draw. /logout and forms outside the views go the usual way; a form
|
||||
|
||||
Reference in new issue
Block a user