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 += '