diff --git a/internal/server/ai.go b/internal/server/ai.go index ccfde50..b383e9c 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -348,11 +348,18 @@ func gradioAnswer(raw json.RawMessage, cur string) string { } const aiSystem = `Ты - ассистент панели LPRA (удалённый доступ к машинам: терминалы, файлы, порты). Отвечай по-русски, коротко и по делу. -Ниже - что пользователь сейчас видит в панели: вкладки, активная панель, текст терминала или список файлов. -Если нужно выполнить команду, дай её отдельным блоком -` + "```run\nкоманда\n```" + ` -Пользователь сам нажмёт «Выполнить» - команда уйдёт в активный терминал с его правами. Одна команда на блок, без пояснений внутри блока. -Не выдумывай вывод команд: после выполнения ты увидишь его в следующем сообщении. Опасные команды (удаление, перезапуск, изменение системы) - только с предупреждением.` +Ниже - что пользователь сейчас видит в панели: его машины, вкладки, активная панель, текст терминала или список файлов. +Действия - отдельными блоками, панель выполнит их с правами пользователя: +` + "```run машина\nкоманда\n```" + ` - команда в терминале этой машины (машину можно не указывать - тогда активная); +` + "```read машина:/путь\n```" + ` - прочитать файл; +` + "```ls машина:/путь\n```" + ` - список папки; +` + "```write машина:/путь\nполное новое содержимое файла\n```" + ` - записать файл целиком. +Имя машины - как в списке машин. Одна команда на блок, без пояснений внутри блока. Результат действия придёт следующим сообщением - не выдумывай его. +Опасные команды (удаление, перезапуск, изменение системы) - только с предупреждением. Перед правкой файла сначала прочитай его.` + +const aiAgent = ` +Режим агента: ты сам ведёшь задачу шаг за шагом. В каждом ответе - коротко, что делаешь, и ровно одно действие. Получив результат, решай следующий шаг. +Когда задача решена (или решить нельзя) - ответь итогом без блоков действий.` // POST /api/panel/ai/chat - {messages, context}: the answer, streamed as // plain text. A failure after the start ends the text with "[ошибка: …]". @@ -365,6 +372,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { var in struct { Messages []aiMsg `json:"messages"` Context string `json:"context"` + Agent bool `json:"agent"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil || len(in.Messages) == 0 { http.Error(w, "плохой запрос", http.StatusBadRequest) @@ -400,7 +408,11 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { fl.Flush() } } - err := aiStream(r.Context(), s.aiConfig(), aiSystem+"\n\n--- Панель сейчас ---\n"+in.Context, in.Messages, emit) + system := aiSystem + if in.Agent { + system += aiAgent + } + err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, in.Messages, emit) if err != nil && r.Context().Err() == nil { if !wrote { w.WriteHeader(http.StatusBadGateway) diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index aa479ee..0714131 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -20,9 +20,10 @@ box.className = 'ai-box'; box.innerHTML = '
Ассистент' + + '' + '' + '
' + - '

Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить».

' + + '

Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.

' + '
' + '
'; 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 += '
' + esc(part).replace(/\n/g, '
') + '
'; 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 += '
' + esc(code) + '
' + - (lang === 'run' && closed && code.trim() ? '' : '') + '
'; + const act = LABEL[kind] && closed; + html += '
' + (act && head !== kind ? '
' + esc(head) + '
' : '') + + (code || !act ? '
' + esc(code) + '
' : '') + + (act ? '' : '') + '
'; }); 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 = '
Результат
'; + 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'; } diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 4885f31..3af4e7c 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -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; } diff --git a/internal/server/web/static/wsutil.js b/internal/server/web/static/wsutil.js index 95a18e0..651702a 100644 --- a/internal/server/web/static/wsutil.js +++ b/internal/server/web/static/wsutil.js @@ -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); diff --git a/internal/server/web/static/wsutil.test.js b/internal/server/web/static/wsutil.test.js index de57c76..3db7b13 100644 --- a/internal/server/web/static/wsutil.test.js +++ b/internal/server/web/static/wsutil.test.js @@ -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); +});