feat: the assistant's talk is kept in the database per user and the model sees a trimmed window of it; clear is a broom; the ball steps aside for the chat and comes back where the chat was
This commit is contained in:
1 parent
92a90646a5
commit
00617451aa
6 files changed
+286
-63
No files matched your search
@@ -21,7 +21,7 @@
|
||||
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>' +
|
||||
'<button type="button" class="btn ghost small icon sq ai-clear" title="Очистить разговор" aria-label="Очистить разговор"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 4 5 5"/><path d="M17.5 6.5 11 13"/><path d="M11 13c-2-2-5-1.5-6.5 0L3 14.5 9.5 21l1.5-1.5c1.5-1.5 2-4.5 0-6.5z"/><path d="m6 17 2-2M8.5 19.5 10 18"/></svg></button>' +
|
||||
'<button type="button" class="btn ghost small icon close x ai-close" aria-label="Закрыть"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>' +
|
||||
'<div class="ai-list"><p class="muted ai-hello">Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.</p></div>' +
|
||||
'<div class="ai-pills">' +
|
||||
@@ -35,7 +35,8 @@
|
||||
document.body.append(fab, box);
|
||||
const q = s => box.querySelector(s);
|
||||
const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send');
|
||||
const talk = []; // {role, content}
|
||||
// The talk lives on the server (the database); here only what is shown.
|
||||
let loaded = false;
|
||||
let ac = null;
|
||||
|
||||
// Open and shut with a short slide. On a phone the chat takes the screen
|
||||
@@ -43,8 +44,10 @@
|
||||
const phone = () => matchMedia('(max-width: 640px)').matches;
|
||||
const isOpen = () => box.classList.contains('on');
|
||||
function show() {
|
||||
loadTalk();
|
||||
placeBox();
|
||||
box.classList.add('on');
|
||||
fab.classList.add('away');
|
||||
if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href);
|
||||
where();
|
||||
if (!phone()) input.focus();
|
||||
@@ -52,10 +55,24 @@
|
||||
function hide() {
|
||||
if (!isOpen()) return;
|
||||
if (history.state && history.state.aiChat) { history.back(); return; }
|
||||
box.classList.remove('on');
|
||||
shut();
|
||||
}
|
||||
window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) box.classList.remove('on'); });
|
||||
// shut: the chat goes, and the ball comes back in its corner - where the
|
||||
// chat was last moved to.
|
||||
function shut() {
|
||||
if (desk && !phone()) {
|
||||
const b = box.getBoundingClientRect(), o = box.style.transformOrigin || 'bottom right';
|
||||
const right = /right/.test(o) ? innerWidth - b.right : innerWidth - b.left - fab.offsetWidth;
|
||||
const bottom = /bottom/.test(o) ? innerHeight - b.bottom : innerHeight - b.top - fab.offsetHeight;
|
||||
const pos = placeFab(right, bottom);
|
||||
try { localStorage.setItem('lpra.ai.pos', JSON.stringify(pos)); } catch (e) { /* not kept */ }
|
||||
}
|
||||
box.classList.remove('on');
|
||||
fab.classList.remove('away');
|
||||
}
|
||||
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(() => {});
|
||||
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
|
||||
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
|
||||
@@ -64,7 +81,6 @@
|
||||
// The ball goes where it is dragged and stays there (kept in this
|
||||
// browser, as distances from the right and bottom edges); the chat opens
|
||||
// beside it, toward the room, and moves by its title.
|
||||
const desk = !matchMedia('(pointer: coarse)').matches;
|
||||
let moved = false;
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
function placeFab(right, bottom) {
|
||||
@@ -77,10 +93,9 @@
|
||||
if (!desk || phone()) { box.style.left = box.style.top = box.style.right = box.style.bottom = ''; return; }
|
||||
const f = fab.getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight, gap = 12;
|
||||
const toLeft = f.left + f.width / 2 > innerWidth / 2, up = f.top + f.height / 2 > innerHeight / 2;
|
||||
let x = toLeft ? f.right - w : f.left;
|
||||
let y = up ? f.top - gap - h : f.bottom + gap;
|
||||
// No room above or below: beside the ball.
|
||||
if (y < 8 || y + h > innerHeight - 8) { y = clamp(f.top + f.height - h, 8, innerHeight - h - 8); x = toLeft ? f.left - gap - w : f.right + gap; }
|
||||
// The chat takes the ball's place, grown from its corner.
|
||||
const x = toLeft ? f.right - w : f.left;
|
||||
const y = up ? f.bottom - h : f.top;
|
||||
box.style.right = box.style.bottom = 'auto';
|
||||
box.style.left = clamp(x, 8, innerWidth - w - 8) + 'px';
|
||||
box.style.top = clamp(y, 8, innerHeight - h - 8) + 'px';
|
||||
@@ -126,12 +141,25 @@
|
||||
});
|
||||
}
|
||||
q('.ai-close').onclick = hide;
|
||||
q('.ai-clear').onclick = () => {
|
||||
q('.ai-clear').onclick = async () => {
|
||||
if (!list.querySelector('.ai-msg') || !await UI.confirm('Очистить разговор?', {ok: 'Очистить', danger: true})) return;
|
||||
if (ac) ac.abort();
|
||||
talk.length = 0;
|
||||
fetch('/api/panel/ai/clear', {method: 'POST', credentials: 'same-origin'});
|
||||
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
|
||||
q('.ai-hello').hidden = false;
|
||||
};
|
||||
// The talk so far, once, when the chat first opens.
|
||||
async function loadTalk() {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
try {
|
||||
const h = await fetch('/api/panel/ai/history', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json());
|
||||
h.forEach(m => {
|
||||
if (m.role === 'result') showResult(m.content);
|
||||
else { const el = add(m.role, m.content); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
|
||||
});
|
||||
} catch (e) { loaded = false; }
|
||||
}
|
||||
|
||||
// ---------- what the panel shows ----------
|
||||
const W = () => window.Workspace;
|
||||
@@ -304,12 +332,12 @@
|
||||
list.scrollTop = list.scrollHeight;
|
||||
return el;
|
||||
}
|
||||
// A result goes into the talk as the user's message and shows folded.
|
||||
function result(text) {
|
||||
// An action's result shows folded; it reaches the talk on the server with
|
||||
// the next question (or by note, when done by hand).
|
||||
function showResult(text) {
|
||||
const el = add('result', '');
|
||||
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
|
||||
el.querySelector('pre').textContent = text;
|
||||
talk.push({role: 'user', content: text});
|
||||
}
|
||||
list.addEventListener('click', async ev => {
|
||||
const b = ev.target.closest('.ai-act');
|
||||
@@ -320,11 +348,14 @@
|
||||
b.textContent = '…';
|
||||
const res = await perform(a);
|
||||
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
||||
result(res);
|
||||
showResult(res);
|
||||
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({content: res})});
|
||||
});
|
||||
|
||||
async function ask(text) {
|
||||
if (text != null) { talk.push({role: 'user', content: text}); add('user', text); }
|
||||
// ask sends the new words (or an action's result) and shows the answer.
|
||||
async function ask(text, res) {
|
||||
if (text) add('user', text);
|
||||
if (res) showResult(res);
|
||||
const el = add('assistant', '');
|
||||
el.classList.add('wait');
|
||||
ac = new AbortController();
|
||||
@@ -334,7 +365,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({messages: talk, context: context(), agent: agentOn(), think: thinkOn()})
|
||||
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn()})
|
||||
});
|
||||
if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; }
|
||||
if (!r.body) throw new Error('ошибка ' + r.status);
|
||||
@@ -352,7 +383,6 @@
|
||||
} finally {
|
||||
el.classList.remove('wait');
|
||||
render(el, answer || '[нет ответа]', true);
|
||||
talk.push({role: 'assistant', content: stripThink(answer)});
|
||||
}
|
||||
return {el, answer: stripThink(answer)};
|
||||
}
|
||||
@@ -379,16 +409,14 @@
|
||||
const b = turn.el.querySelector('.ai-act');
|
||||
if (needsYes(a) && !await UI.confirm(describe(a), {ok: a.kind === 'run' ? 'Выполнить' : 'Записать'})) {
|
||||
if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
|
||||
result('Пользователь отклонил это действие. Предложи другое или закончи.');
|
||||
turn = await ask(null);
|
||||
turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.');
|
||||
continue;
|
||||
}
|
||||
if (b) { b.disabled = true; b.textContent = '…'; }
|
||||
const res = await perform(a, ac.signal);
|
||||
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
||||
result(res);
|
||||
if (ac.signal.aborted) break;
|
||||
turn = await ask(null);
|
||||
if (ac.signal.aborted) { showResult(res); break; }
|
||||
turn = await ask(null, res);
|
||||
}
|
||||
ac = null;
|
||||
send.innerHTML = PLANE; send.title = 'Отправить';
|
||||
|
||||
@@ -888,3 +888,8 @@ body:not(.touch) .ai-fab { cursor:grab; touch-action:none; }
|
||||
body.touch.term-active .ai-fab { bottom:110px; }
|
||||
.ai-form-in textarea { font-size:16px; }
|
||||
}
|
||||
.ai-clear { width:30px; height:30px; padding:0; display:inline-flex; align-items:center; justify-content:center; }
|
||||
/* The ball steps aside while the chat is open, and back when it shuts. */
|
||||
.ai-fab { transition:transform .22s ease, opacity .22s ease, box-shadow .2s ease, right .25s ease, bottom .25s ease; }
|
||||
.ai-fab.away { opacity:0; transform:scale(.5); pointer-events:none; }
|
||||
body.ai-dragging .ai-fab, body.ai-dragging .ai-box { transition:none; }
|
||||
Reference in new issue
Block a user