187 lines
9.9 KiB
JavaScript
187 lines
9.9 KiB
JavaScript
// 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 = '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M2.4 10.6 20.6 3.3c.8-.3 1.5.4 1.2 1.2l-7.3 18.2c-.3.8-1.5.8-1.8 0l-2.6-6.9-6.9-2.6c-.8-.3-.8-1.5 0-1.8zm7.3 4.4 2 5.2L18 6 9.7 15z"/></svg>';
|
|
const STOP = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>';
|
|
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 = '<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.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 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>' +
|
|
'<form class="ai-form-in"><textarea rows="1" placeholder="Сообщение…"></textarea>' +
|
|
'<button type="submit" class="ai-send" title="Отправить" aria-label="Отправить">' + PLANE + '</button></form>';
|
|
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}
|
|
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() {
|
|
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 = () => (isOpen() ? hide() : show());
|
|
q('.ai-close').onclick = hide;
|
|
q('.ai-clear').onclick = () => {
|
|
if (ac) ac.abort();
|
|
talk.length = 0;
|
|
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
|
|
q('.ai-hello').hidden = false;
|
|
};
|
|
|
|
// ---------- 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)];
|
|
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 : '') : '';
|
|
}
|
|
|
|
// ---------- messages ----------
|
|
// render: text with ``` blocks; ```run``` ones get «Выполнить».
|
|
function render(el, text, done) {
|
|
const parts = text.split(/```/);
|
|
let html = '';
|
|
parts.forEach((part, i) => {
|
|
if (i % 2 === 0) { html += '<div class="ai-text">' + esc(part).replace(/\n/g, '<br>') + '</div>'; return; }
|
|
const m = /^(\w*)\n?([\s\S]*)$/.exec(part);
|
|
const lang = m[1], code = m[2].replace(/\n$/, '');
|
|
const closed = i < parts.length - 1 || done;
|
|
html += '<div class="ai-code"><pre>' + esc(code) + '</pre>' +
|
|
(lang === 'run' && closed && code.trim() ? '<button type="button" class="btn small ai-run" data-cmd="' + esc(code) + '">Выполнить</button>' : '') + '</div>';
|
|
});
|
|
el.innerHTML = html;
|
|
}
|
|
function add(role, text) {
|
|
q('.ai-hello').hidden = true;
|
|
const el = document.createElement('div');
|
|
el.className = 'ai-msg ' + role;
|
|
if (role === 'user') el.textContent = text; else render(el, text, true);
|
|
list.appendChild(el);
|
|
list.scrollTop = list.scrollHeight;
|
|
return el;
|
|
}
|
|
list.addEventListener('click', ev => {
|
|
const b = ev.target.closest('.ai-run');
|
|
if (!b) return;
|
|
const ws = W(), act = ws && ws.activePane();
|
|
let t = act && act.term ? act : null;
|
|
if (!t && ws) t = ws.panes().find(p => p.term && ws.tabOf(p) === ws.tabOf(act)) || null;
|
|
if (!t) { FileViews.toast('Откройте терминал: команда выполняется в активном терминале', true); return; }
|
|
t.send(b.dataset.cmd.replace(/\n/g, '\r') + '\r');
|
|
b.textContent = 'Выполнено в ' + (t.agent ? t.agent.name : 'терминале');
|
|
b.disabled = true;
|
|
});
|
|
|
|
async function ask(text) {
|
|
talk.push({role: 'user', content: text});
|
|
add('user', text);
|
|
const el = add('assistant', '');
|
|
el.classList.add('wait');
|
|
ac = new AbortController();
|
|
send.innerHTML = STOP; send.title = 'Остановить';
|
|
let answer = '';
|
|
try {
|
|
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()})
|
|
});
|
|
if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; }
|
|
if (!r.ok && !r.body) throw new Error('ошибка ' + r.status);
|
|
const rd = r.body.getReader(), dec = new TextDecoder();
|
|
for (;;) {
|
|
const {done, value} = await rd.read();
|
|
if (done) break;
|
|
answer += dec.decode(value, {stream: true});
|
|
el.classList.remove('wait');
|
|
render(el, answer, false);
|
|
list.scrollTop = list.scrollHeight;
|
|
}
|
|
} catch (e) {
|
|
if (e.name !== 'AbortError') answer += (answer ? '\n' : '') + '[ошибка: ' + (e.message || 'нет связи') + ']';
|
|
} finally {
|
|
ac = null;
|
|
send.innerHTML = PLANE; send.title = 'Отправить';
|
|
el.classList.remove('wait');
|
|
render(el, answer || '[нет ответа]', true);
|
|
talk.push({role: 'assistant', content: answer});
|
|
}
|
|
}
|
|
q('.ai-form-in').onsubmit = ev => {
|
|
ev.preventDefault();
|
|
if (ac) { ac.abort(); return; }
|
|
const text = input.value.trim();
|
|
if (!text) return;
|
|
input.value = '';
|
|
grow();
|
|
where();
|
|
ask(text);
|
|
};
|
|
// The field grows with the text, up to a few lines.
|
|
function grow() { input.style.height = '40px'; input.style.height = Math.min(140, input.scrollHeight + 2) + 'px'; }
|
|
input.addEventListener('input', grow);
|
|
input.addEventListener('keydown', ev => {
|
|
if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); q('.ai-form-in').requestSubmit(); }
|
|
});
|
|
})();
|