// The assistant: a round button in the bottom right corner opens a chat. Each
// question goes with what the panel shows - the tabs, the active pane, the
// text of its terminal or its list of files - and the answer streams in.
// Commands the model suggests come as ```run``` blocks with «Выполнить»: a
// click sends the command to the active terminal, with the user's rights.
// Off until the user turns it on in the profile.
(function () {
'use strict';
const PLANE = '';
const STOP = '';
const esc = v => String(v).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
const fab = document.createElement('button');
fab.type = 'button';
fab.className = 'ai-fab';
fab.hidden = true;
fab.title = 'ИИ-ассистент';
fab.setAttribute('aria-label', 'ИИ-ассистент');
fab.innerHTML = '';
const box = document.createElement('div');
box.className = 'ai-box';
box.innerHTML =
'
' +
'' +
'
' +
'
' +
'
Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.
' +
'
' +
'' +
'' +
'' +
'
' +
'';
document.body.append(fab, box);
const q = s => box.querySelector(s);
const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send');
// 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
// and is one step of history: «назад» shuts it.
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();
}
function hide() {
if (!isOpen()) return;
if (history.state && history.state.aiChat) { history.back(); return; }
shut();
}
// 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(); };
// ---------- on a computer: the ball and the chat move ----------
// 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.
let moved = false;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function placeFab(right, bottom) {
right = clamp(right, 8, innerWidth - fab.offsetWidth - 8);
bottom = clamp(bottom, 8, innerHeight - fab.offsetHeight - 8);
fab.style.right = right + 'px'; fab.style.bottom = bottom + 'px';
return {right, bottom};
}
function placeBox() {
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;
// 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';
box.style.transformOrigin = (up ? 'bottom ' : 'top ') + (toLeft ? 'right' : 'left');
}
if (desk) {
try { const p = JSON.parse(localStorage.getItem('lpra.ai.pos') || 'null'); if (p) requestAnimationFrame(() => placeFab(p.right, p.bottom)); } catch (e) { /* default */ }
const drag = (el, ev, onMove, onEnd) => {
if (ev.button !== 0) return;
const x0 = ev.clientX, y0 = ev.clientY;
let on = false;
const mv = e => {
if (!on && Math.hypot(e.clientX - x0, e.clientY - y0) < 5) return;
on = true;
onMove(e.clientX - x0, e.clientY - y0);
};
const up = () => {
removeEventListener('pointermove', mv); removeEventListener('pointerup', up);
document.body.classList.remove('ai-dragging');
if (on) onEnd();
};
addEventListener('pointermove', mv); addEventListener('pointerup', up);
document.body.classList.add('ai-dragging');
};
fab.addEventListener('pointerdown', ev => {
const r0 = innerWidth - fab.getBoundingClientRect().right, b0 = innerHeight - fab.getBoundingClientRect().bottom;
let pos = {right: r0, bottom: b0};
drag(fab, ev, (dx, dy) => { moved = true; pos = placeFab(r0 - dx, b0 - dy); if (isOpen()) placeBox(); }, () => {
try { localStorage.setItem('lpra.ai.pos', JSON.stringify(pos)); } catch (e) { /* not kept */ }
});
});
q('.ai-head').addEventListener('pointerdown', ev => {
if (ev.target.closest('button')) return;
const r = box.getBoundingClientRect();
drag(box, ev, (dx, dy) => {
box.style.left = clamp(r.left + dx, 8, innerWidth - r.width - 8) + 'px';
box.style.top = clamp(r.top + dy, 8, innerHeight - r.height - 8) + 'px';
}, () => {});
});
addEventListener('resize', () => {
try { const p = JSON.parse(localStorage.getItem('lpra.ai.pos') || 'null'); if (p) placeFab(p.right, p.bottom); } catch (e) { /* default */ }
if (isOpen()) placeBox();
});
}
q('.ai-close').onclick = hide;
// ---------- chats ----------
// Several talks, kept on the server. The header names the current one;
// its arrow opens the list: a click opens a chat, the bin deletes it.
let chatId = 0;
try { chatId = Number(localStorage.getItem('lpra.ai.chat')) || 0; } catch (e) { /* none */ }
const TRASH = '';
const chatsEl = q('.ai-chats'), chatSel = q('.ai-chatsel');
let chats = [];
function setChat(id) {
chatId = id || 0;
try { localStorage.setItem('lpra.ai.chat', String(chatId)); } catch (e) { /* not kept */ }
const c = chats.find(x => x.id === chatId);
q('.ai-chatname').textContent = c ? c.title : 'Новый чат';
q('.ai-chatname').title = c ? c.title : '';
}
async function loadChats() {
try { chats = await fetch('/api/panel/ai/chats', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()); } catch (e) { chats = []; }
if (chatId && !chats.some(c => c.id === chatId)) chatId = 0;
setChat(chatId);
drawChats();
}
function drawChats() {
const box = q('.ai-chatlist');
box.innerHTML = '';
if (!chats.length) box.innerHTML = '
Пока нет чатов
';
chats.forEach(c => {
const row = document.createElement('div');
row.className = 'ai-chatrow' + (c.id === chatId ? ' on' : '');
row.innerHTML = '';
row.querySelector('.ai-chatopen').textContent = c.title;
row.querySelector('.ai-chatopen').title = c.title;
row.querySelector('.ai-chatopen').onclick = () => { openChat(c.id); toggleChats(false); };
row.querySelector('.ai-chatdel').onclick = async ev => {
ev.stopPropagation();
if (!await UI.confirm('Удалить чат «' + c.title + '»?', {ok: 'Удалить', danger: true})) return;
await fetch('/api/panel/ai/chats/' + c.id + '/delete', {method: 'POST', credentials: 'same-origin'});
if (c.id === chatId) openChat(0);
loadChats();
};
box.appendChild(row);
});
}
function toggleChats(on) {
on = on == null ? chatSel.getAttribute('aria-expanded') !== 'true' : on;
chatSel.setAttribute('aria-expanded', String(on));
chatsEl.classList.toggle('open', on);
chatsEl.inert = !on;
if (on) loadChats();
}
chatSel.onclick = () => toggleChats();
q('.ai-chatnew').onclick = () => { openChat(0); toggleChats(false); input.focus(); };
document.addEventListener('pointerdown', ev => { if (chatsEl.classList.contains('open') && !chatsEl.contains(ev.target) && !chatSel.contains(ev.target)) toggleChats(false); }, true);
// openChat shows a chat's talk (0: a new, empty one).
async function openChat(id) {
if (ac) ac.abort();
setChat(id);
drawChats();
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
q('.ai-hello').hidden = !!id;
if (!id) return;
try {
const h = await fetch('/api/panel/ai/chats/' + id, {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json());
if (chatId !== id) return;
h.forEach(m => {
if (m.role === 'result') showResult(m.content);
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
});
if (!h.length) q('.ai-hello').hidden = false;
} catch (e) { /* stays empty */ }
}
// The chat last open, when the window first opens.
async function loadTalk() {
if (loaded) return;
loaded = true;
await loadChats();
openChat(chatId);
}
// ---------- what the panel shows ----------
const W = () => window.Workspace;
function termText(p, lines) {
const b = p.term && p.term.buffer && p.term.buffer.active;
if (!b) return '';
const out = [];
for (let i = Math.max(0, b.length - lines); i < b.length; i++) {
const l = b.getLine(i);
if (l) out.push(l.translateToString(true));
}
while (out.length && !out[out.length - 1].trim()) out.pop();
return out.join('\n');
}
function paneLine(p) {
const kind = {term: 'терминал', ssh: 'SSH-терминал', files: 'файлы', sshfiles: 'файлы по SSH'}[p.kind] || p.kind;
return kind + ' · ' + (p.tooltip ? p.tooltip() : p.title()) + ' (машина ' + (p.agent ? p.agent.name : '?') + ')';
}
function context() {
const view = document.querySelector('main#app').dataset.view;
const out = ['Страница: ' + ({machines: 'Машины', terminal: 'WorkSpace', settings: 'Настройки'}[view] || view)];
out.push('Машины пользователя: ' + machines.map(m => m.name + ' (' + m.user + '@' + m.hostname + (m.online ? '' : ', офлайн') + ')').join('; '));
const ws = W();
if (!ws) return out.join('\n');
const panes = ws.panes(), act = ws.activePane();
out.push('Панели (' + panes.length + '):');
panes.forEach(p => out.push((p === act ? '* ' : '- ') + paneLine(p)));
if (act && act.term) {
out.push('', 'Активный терминал, последние строки:', '```', termText(act, 120), '```');
} else if (act && act.entries) {
out.push('', 'Активная файловая панель: ' + act.cwd + (act.selected.size ? ' · выбрано: ' + [...act.selected].join(', ') : ''));
out.push(act.entries.slice(0, 200).map(e => (e.is_dir ? e.name + '/' : e.name + ' (' + e.size + ' B)')).join('\n'));
}
return out.join('\n');
}
function where() {
const act = W() && W().activePane();
q('.ai-where').textContent = act ? '· ' + (act.agent ? act.agent.name : '') : '';
}
// ---------- machines ----------
let machines = [];
async function loadMachines() {
try { machines = await fetch('/api/panel/agents/presence', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()); } catch (e) { /* keep */ }
return machines;
}
// machineOf: the machine an action names, else the one of the active pane.
function machineOf(name) {
const n = (name || '').trim().toLowerCase();
if (n) return machines.find(m => m.name.toLowerCase() === n || m.id === n || m.hostname.toLowerCase() === n) || null;
const act = W() && W().activePane();
if (act && act.agent) return machines.find(m => m.id === act.agent.id) || {id: act.agent.id, name: act.agent.name, online: true};
return machines.length === 1 ? machines[0] : null;
}
// ---------- actions ----------
const sleep = ms => new Promise(r => setTimeout(r, ms));
const lineAt = (b, i) => { const l = b.getLine(i); return l ? l.translateToString(true) : ''; };
// terminalFor: a terminal of machine m - the active one if it is, else any
// open one of it, else a new tab.
async function terminalFor(m) {
const ws = W(), act = ws.activePane();
if (act && act.term && !act.ssh && act.agent.id === m.id) return act;
let t = ws.panes().find(p => p.term && !p.ssh && p.agent.id === m.id);
if (!t) {
if (document.querySelector('main#app').dataset.view !== 'terminal') App.go('/workspace');
ws.open('term', {id: m.id, name: m.name, host: m.hostname, user: m.user});
t = ws.panes().find(p => p.term && !p.ssh && p.agent.id === m.id);
for (let i = 0; i < 40 && t && t.state !== 'ok'; i++) await sleep(250);
await sleep(600);
}
return t;
}
// runIn sends cmd to terminal t and gives what it printed: until the
// output has been still for 1.5 s (up to 2 minutes).
async function runIn(t, cmd, signal) {
const b = t.term.buffer.active;
const start = b.baseY + b.cursorY;
t.send(cmd.replace(/\n/g, '\r') + '\r');
let last = '', still = 0;
for (let ms = 0; ms < 120000 && !(signal && signal.aborted); ms += 300) {
await sleep(300);
const end = b.baseY + b.cursorY;
const now = end + ':' + lineAt(b, end);
if (now === last) { if ((still += 300) >= 1500) break; } else { still = 0; last = now; }
}
const lines = [];
for (let i = start; i <= b.baseY + b.cursorY; i++) lines.push(lineAt(b, i));
let out = lines.join('\n').replace(/\s+$/, '');
if (out.length > 8000) out = '…' + out.slice(-8000);
return out;
}
const b64 = text => { const u = new TextEncoder().encode(text); let s = ''; for (let i = 0; i < u.length; i++) s += String.fromCharCode(u[i]); return btoa(s); };
// perform does one action with the user's rights and tells what came of it.
async function perform(a, signal) {
// A web search: through the server, only with «Поиск» on.
if (a.kind === 'search') {
if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»';
const r = await fetch('/api/panel/ai/search', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin',
body: JSON.stringify({q: a.body})}).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи'}));
if (!r.ok) return 'Ошибка поиска «' + a.body + '»' + (r.engine ? ' (' + r.engine + ')' : '') + ': ' + r.error;
return 'Результаты поиска «' + a.body + '» через ' + (r.engine || 'поисковик') + ':\n' + r.data.map((x, i) => (i + 1) + '. ' + x.title + ' - ' + x.url + (x.snippet ? '\n ' + x.snippet : '')).join('\n');
}
await loadMachines();
const m = machineOf(a.machine);
if (!m) return 'Ошибка: нет машины «' + (a.machine || '?') + '». Машины: ' + machines.map(x => x.name).join(', ');
if (m.online === false) return 'Ошибка: машина ' + m.name + ' офлайн';
const src = new MachineSource({id: m.id, name: m.name});
if (a.kind === 'run') {
const t = await terminalFor(m);
if (!t) return 'Ошибка: терминал на ' + m.name + ' не открылся';
return 'Вывод на ' + m.name + ':\n```\n' + await runIn(t, a.body, signal) + '\n```';
}
if (a.kind === 'read') {
const r = await src.op('read', {path: a.path});
if (!r.ok) return 'Ошибка чтения ' + m.name + ':' + a.path + ': ' + r.error;
if (r.data.binary) return m.name + ':' + a.path + ' - двоичный файл, ' + r.data.size + ' B';
let text = new TextDecoder().decode(FileViews.b64ToBytes(r.data.content));
if (text.length > 20000) text = text.slice(0, 20000) + '\n…';
return 'Файл ' + m.name + ':' + a.path + (r.data.truncated ? ' (начало)' : '') + ':\n```\n' + text + '\n```';
}
if (a.kind === 'ls') {
const r = await src.op('list', {path: a.path || '~'});
if (!r.ok) return 'Ошибка: ' + r.error;
return 'Папка ' + m.name + ':' + r.data.path + ':\n' + (r.data.entries || []).slice(0, 300)
.map(e => (e.is_dir ? e.name + '/' : e.name + ' ' + e.size + ' B')).join('\n');
}
if (a.kind === 'write') {
const r = await src.op('write', {path: a.path, data: b64(a.body + '\n')});
return r.ok ? 'Записано: ' + m.name + ':' + a.path : 'Ошибка записи: ' + r.error;
}
return 'Ошибка: неизвестное действие';
}
// ask first for what changes something: commands and writes.
function needsYes(a) { return a.kind === 'run' || a.kind === 'write'; }
// ask what a command or a write is about to do: what, where, the command
// (or the start of the text) in mono.
function confirmAction(a) {
const m = machineOf(a.machine), where = m ? m.name : (a.machine || 'активная машина');
if (a.kind === 'run') return UI.confirm('Выполнить команду на {chip}?', {ok: 'Выполнить', chip: where, code: a.body});
const lines = a.body.split('\n');
return UI.confirm('Записать файл {chip}?', {ok: 'Записать', chip: where + ':' + a.path,
code: lines.slice(0, 12).join('\n') + (lines.length > 12 ? '\n…' : ''),
note: 'Файл будет заменён целиком (' + a.body.length + ' символов).'});
}
// ---------- messages ----------
// stripThink: the answer without its thinking - what is acted on and kept.
const stripThink = t => t.replace(/^\s*(?:|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, '');
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск'};
const IC = d => '';
const ICONS = {run: IC(''), read: IC(''),
ls: IC(''), write: IC(''),
search: IC('')};
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти'};
// render: text with ``` blocks; action blocks get their button.
// render draws an answer: its thinking, folded, above; the text with
// Markdown; ``` blocks apart, action blocks with their button. The
// thinking block is made once and only its words change, so it stays open
// or shut as the user left it while the answer streams.
// The arrow of the + menu's groups: right shut, down open.
const CHEV = '›';
function render(el, text, done) {
let think = el.querySelector(':scope > .ai-think'), body = el.querySelector(':scope > .ai-body');
if (!body) {
el.innerHTML = '
' +
'
';
think = el.querySelector('.ai-think'); body = el.querySelector('.ai-body');
think.querySelector('.ai-think-h').onclick = () => think.classList.toggle('open');
}
const th = /^\s*(?:|::думаю::)([\s\S]*?)(<\/think>|::ответ::|$)/.exec(text);
if (th) {
const going = !th[2] && !done;
if (!el._t0) el._t0 = Date.now();
if (!going && !el._t1) el._t1 = Date.now();
think.hidden = false;
think.classList.toggle('going', going);
// How long: timed here as it streams, or kept with the answer.
const ms = el._thinkMs || (el._t1 - el._t0);
think.querySelector('.ai-think-h .lbl').textContent = going ? 'Думаю…' : 'Думал ' + Math.max(1, Math.round(ms / 1000)) + ' сек.';
think.querySelector('.ai-think-in').innerHTML = WSUtil.md(th[1].trim());
text = text.slice(th[0].length);
}
let html = '', n = 0;
const parts = text.split(/```/);
parts.forEach((part, i) => {
if (i % 2 === 0) { if (part.trim()) html += '
' + WSUtil.md(part) + '
'; return; }
const m = /^([^\n]*)\n?([\s\S]*)$/.exec(part);
const head = m[1].trim(), kind = head.split(/\s+/)[0], code = m[2].replace(/\n$/, '');
const closed = i < parts.length - 1 || done;
const act = LABEL[kind] && closed;
// An action: a card with what and where on its title line, a small
// button on the right of it, the command or text below.
if (act) {
const where = head.slice(kind.length).trim();
html += '