feat: assistant reads, lists and writes files and runs commands on any of the user's machines; agent mode does the steps itself, asking before each command and write
This commit is contained in:
1 parent
d9c7c2dd42
commit
a7e43ab700
5 files changed
+207
-31
No files matched your search
@@ -20,9 +20,10 @@
|
||||
box.className = 'ai-box';
|
||||
box.innerHTML =
|
||||
'<div class="ai-head"><b>Ассистент</b><span class="muted ai-where"></span><span class="spacer"></span>' +
|
||||
'<label class="ai-agent" title="Агент: сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла"><input type="checkbox"> Агент</label>' +
|
||||
'<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>' +
|
||||
'<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);
|
||||
@@ -79,6 +80,7 @@
|
||||
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();
|
||||
@@ -97,57 +99,161 @@
|
||||
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('/terminal');
|
||||
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) {
|
||||
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'; }
|
||||
function describe(a) {
|
||||
const where = a.machine ? a.machine + ': ' : '';
|
||||
return a.kind === 'run' ? 'Выполнить на ' + (a.machine || 'активной машине') + ':\n\n' + a.body
|
||||
: 'Записать файл ' + where + a.path + ' (' + a.body.length + ' символов)?';
|
||||
}
|
||||
|
||||
// ---------- messages ----------
|
||||
// render: text with ``` blocks; ```run``` ones get «Выполнить».
|
||||
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Показать', write: 'Записать'};
|
||||
// render: text with ``` blocks; action blocks get their button.
|
||||
function render(el, text, done) {
|
||||
const parts = text.split(/```/);
|
||||
let html = '';
|
||||
let html = '', n = 0;
|
||||
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 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;
|
||||
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>';
|
||||
const act = LABEL[kind] && closed;
|
||||
html += '<div class="ai-code">' + (act && head !== kind ? '<div class="muted ai-cap">' + esc(head) + '</div>' : '') +
|
||||
(code || !act ? '<pre>' + esc(code) + '</pre>' : '') +
|
||||
(act ? '<button type="button" class="btn small ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button>' : '') + '</div>';
|
||||
});
|
||||
el.innerHTML = html;
|
||||
el.dataset.text = text;
|
||||
}
|
||||
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);
|
||||
if (role === 'assistant') render(el, text, true); else el.textContent = text;
|
||||
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 : 'терминале');
|
||||
// A result goes into the talk as the user's message and shows folded.
|
||||
function result(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');
|
||||
if (!b || b.disabled) return;
|
||||
const msg = b.closest('.ai-msg'), a = WSUtil.parseActions(msg.dataset.text || '')[Number(b.dataset.i)];
|
||||
if (!a) return;
|
||||
b.disabled = true;
|
||||
b.textContent = '…';
|
||||
const res = await perform(a);
|
||||
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
||||
result(res);
|
||||
});
|
||||
|
||||
async function ask(text) {
|
||||
talk.push({role: 'user', content: text});
|
||||
add('user', text);
|
||||
if (text != null) { 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 {
|
||||
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()})
|
||||
body: JSON.stringify({messages: talk, context: context(), agent: agentOn()})
|
||||
});
|
||||
if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; }
|
||||
if (!r.ok && !r.body) throw new Error('ошибка ' + r.status);
|
||||
if (!r.body) throw new Error('ошибка ' + r.status);
|
||||
const rd = r.body.getReader(), dec = new TextDecoder();
|
||||
for (;;) {
|
||||
const {done, value} = await rd.read();
|
||||
@@ -160,12 +266,37 @@
|
||||
} 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});
|
||||
}
|
||||
return {el, answer};
|
||||
}
|
||||
const agentOn = () => q('.ai-agent input').checked;
|
||||
// talkTurn: one question, and in agent mode the steps after it - the
|
||||
// first action of each answer done (asking first for commands and writes)
|
||||
// and its result sent back - until an answer asks for nothing.
|
||||
async function talkTurn(text) {
|
||||
let turn = await ask(text);
|
||||
for (let step = 0; agentOn() && ac && !ac.signal.aborted && step < 15; step++) {
|
||||
const a = WSUtil.parseActions(turn.answer)[0];
|
||||
if (!a) break;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
ac = null;
|
||||
send.innerHTML = PLANE; send.title = 'Отправить';
|
||||
}
|
||||
q('.ai-form-in').onsubmit = ev => {
|
||||
ev.preventDefault();
|
||||
@@ -175,7 +306,7 @@
|
||||
input.value = '';
|
||||
grow();
|
||||
where();
|
||||
ask(text);
|
||||
talkTurn(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'; }
|
||||
|
||||
@@ -861,3 +861,9 @@ dialog.uidlg { max-width:420px; width:92vw; }
|
||||
.sshdlg .chk input { width:auto; }
|
||||
.sshdlg .saved-row[hidden] { display:none; }
|
||||
.sshdlg .saved-row .frow { align-items:center; }
|
||||
.ai-agent { display:inline-flex; align-items:center; gap:5px; font-size:12.5px; color:var(--muted); cursor:pointer; }
|
||||
.ai-agent input { width:auto; margin:0; }
|
||||
.ai-msg.result { align-self:stretch; max-width:none; padding:4px 10px; font-size:12.5px; color:var(--muted); background:transparent; border:1px dashed var(--border); }
|
||||
.ai-msg.result summary { cursor:pointer; }
|
||||
.ai-msg.result pre { margin:6px 0 2px; max-height:220px; overflow:auto; white-space:pre-wrap; font-size:12px; }
|
||||
.ai-cap { font-size:12px; margin-bottom:4px; }
|
||||
@@ -44,6 +44,20 @@
|
||||
};
|
||||
}
|
||||
|
||||
const api = {shellQuote, shellQuoteAll, dropAction, fmtSize, scope};
|
||||
// parseActions finds what an assistant's answer asks the panel to do:
|
||||
// closed ``` blocks headed run [machine], read|ls [machine:]path or
|
||||
// write [machine:]path (its body the file's text).
|
||||
function parseActions(text) {
|
||||
const out = [], re = /```(run|read|ls|write)([^\n]*)\n([\s\S]*?)```/g;
|
||||
for (let m; (m = re.exec(text));) {
|
||||
const kind = m[1], head = m[2].trim(), body = m[3].replace(/\n$/, '');
|
||||
if (kind === 'run') { out.push({kind, machine: head, path: '', body}); continue; }
|
||||
const c = head.indexOf(':');
|
||||
const machine = c > 0 && !head.startsWith('/') ? head.slice(0, c) : '';
|
||||
out.push({kind, machine, path: machine ? head.slice(c + 1) : head, body: kind === 'write' ? body : ''});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const api = {shellQuote, shellQuoteAll, dropAction, fmtSize, scope, parseActions};
|
||||
if (typeof module !== 'undefined') module.exports = api; else root.WSUtil = api;
|
||||
})(this);
|
||||
@@ -49,3 +49,16 @@ test('scope: nothing new runs after it ended', async () => {
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
assert.strictEqual(n, 0);
|
||||
});
|
||||
|
||||
test('parseActions: run, read, ls and write blocks with or without a machine', () => {
|
||||
const text = 'Сделаю так:\n```run web\nuptime\n```\nи\n```read db:/etc/hosts\n```\n```ls /var/log\n```\n```write web:/tmp/a b.txt\nline 1\nline 2\n```\n```bash\nnot an action\n```\n```run\ndf -h\n```';
|
||||
assert.deepStrictEqual(U.parseActions(text), [
|
||||
{kind: 'run', machine: 'web', path: '', body: 'uptime'},
|
||||
{kind: 'read', machine: 'db', path: '/etc/hosts', body: ''},
|
||||
{kind: 'ls', machine: '', path: '/var/log', body: ''},
|
||||
{kind: 'write', machine: 'web', path: '/tmp/a b.txt', body: 'line 1\nline 2'},
|
||||
{kind: 'run', machine: '', path: '', body: 'df -h'}
|
||||
]);
|
||||
// A block still being written is not an action yet.
|
||||
assert.strictEqual(U.parseActions('```run\nls').length, 0);
|
||||
});
|
||||
Reference in new issue
Block a user