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:
redtea committed 2026-10-15 22:35:10 +03:00
1 parent 92a90646a5
commit 00617451aa
6 files changed
+286 -63

No files matched your search

+52 -24
View File
@@ -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 = 'Отправить';